JB logo
CoffeeyOUTUBE
Blog
Next

Integrating Sentinel v2 into a Go Project — WAF, Rate Limiting & Your Own Security Dashboard

A complete, production-tested guide to adding Sentinel v2 (WAF, rate limiting, auth shield, anomaly detection, geo) to any Go/Gin app — then reading its data in-process to build a security console that fits your app. Every snippet is from a running integration, verified against the library source rather than its README. Go + Gin + GORM on the back end, React + TanStack Query on the front.

Integrating Sentinel v2 into a Go project

A complete, working guide to adding Sentinel to any Go/Gin application and building your own security dashboard on top of it.

Every snippet here is taken from a running production integration, not written from the README. Where the library's documentation and its source disagree, the source is what's described.


Contents

  1. What you get
  2. Install
  3. The smallest thing that works
  4. Production configuration
  5. Five things that will bite you
  6. Reading Sentinel's data
  7. Your own API layer
  8. The dashboard: data hooks
  9. The dashboard: UI
  10. Environment variables
  11. Verifying it works
  12. Troubleshooting

1. What you get

Mounting Sentinel gives you five subsystems as middleware, plus a database of everything they saw:

SubsystemWhat it does
WAFInspects each request for SQL injection, XSS, path traversal, SSRF and command injection. Blocks or logs.
Rate limitingPer-IP and per-route request caps with configurable windows.
Auth shieldWatches your login route for credential stuffing and brute force, and locks out offenders.
Anomaly detectionFlags traffic that deviates from the norm for a client.
GeoAttaches a country to each request, and can block by country.

It ships its own dashboard at /sentinel, but the interesting part is that everything it records is queryable from your own code, which is what lets you build a console that fits your app instead of bolting on a second one.

Two things it is not

  • It is not a replacement for input validation or parameterised queries. A WAF is a net under the trapeze, not the trapeze.
  • It is not a substitute for authentication and authorisation. It will happily let a correctly-authenticated user do something they shouldn't.

2. Install

Sentinel v2 uses a /v2 module path, so it can sit alongside v1 during a migration:

go get github.com/MUKE-coder/sentinel/v2@latest
go mod tidy

Verify what you actually got, because @latest moves:

go list -m github.com/MUKE-coder/sentinel/v2
# github.com/MUKE-coder/sentinel/v2 v2.2.1

If you are upgrading from v1: the import path changes from github.com/MUKE-coder/sentinel to github.com/MUKE-coder/sentinel/v2, and Mount becomes MountE (it now returns an error). Run go mod tidy and then check nothing unrelated moved — go get has a habit of quietly adjusting other dependencies:

go list -m all > /tmp/after.txt   # diff against a copy taken before the upgrade

3. The smallest thing that works

package main
 
import (
    "log"
    "os"
 
    sentinel "github.com/MUKE-coder/sentinel/v2"
    "github.com/gin-gonic/gin"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)
 
func main() {
    db, err := gorm.Open(postgres.Open(os.Getenv("DATABASE_URL")), &gorm.Config{})
    if err != nil {
        log.Fatal(err)
    }
 
    r := gin.New()
    r.Use(gin.Recovery())
 
    if err := sentinel.MountE(r, db, sentinel.Config{
        Dashboard: sentinel.DashboardConfig{
            Username:  "admin",
            Password:  os.Getenv("SENTINEL_PASSWORD"),
            SecretKey: os.Getenv("SENTINEL_SECRET_KEY"),
        },
        WAF: sentinel.WAFConfig{
            Enabled: true,
            Mode:    sentinel.ModeBlock,
        },
    }); err != nil {
        log.Fatalf("sentinel: %v", err)
    }
 
    r.GET("/api/hello", func(c *gin.Context) {
        c.JSON(200, gin.H{"hello": "world"})
    })
 
    r.Run(":8080")
}

Mount Sentinel before your routes. It registers middleware on the engine, and middleware only applies to routes registered after it.

Its dashboard is now at http://localhost:8080/sentinel.


4. Production configuration

This is the real thing — every field that matters, with the reasoning:

excludedRoutes := []string{
    // Sentinel's own UI, plus any other mounted dashboards. Inspecting these
    // is pointless and their payloads trip the rules.
    "/sentinel/**", "/pulse/**", "/studio/**", "/docs/**",
    // Health checks: keep them fast and never rate-limited, or your
    // orchestrator will start killing healthy containers.
    "/api/health",
    // Rich-text endpoints. A page builder or email composer legitimately
    // POSTs HTML, which the XSS rules flag on sight.
    "/api/email/campaigns/**", "/api/email/templates/**",
    "/api/website/pages/**", "/api/website/posts/**",
}
 
cfg := sentinel.Config{
    Dashboard: sentinel.DashboardConfig{
        Username:  os.Getenv("SENTINEL_USERNAME"),
        Password:  os.Getenv("SENTINEL_PASSWORD"),
        SecretKey: os.Getenv("SENTINEL_SECRET_KEY"), // signs dashboard JWTs
    },
 
    // Persist to your application database rather than the default SQLite
    // file beside the binary — that file is wiped by every container redeploy,
    // taking your entire threat history with it.
    Storage: sentinel.StorageConfig{
        Driver: sentinel.Postgres,
        DSN:    os.Getenv("DATABASE_URL"),
    },
 
    WAF: sentinel.WAFConfig{
        Enabled:       true,
        Mode:          sentinel.ModeBlock, // or ModeMonitor to log only
        ExcludeRoutes: excludedRoutes,
        // Required behind any reverse proxy. See §5.2.
        TrustedProxies: strings.Split(os.Getenv("SENTINEL_TRUSTED_PROXIES"), ","),
    },
 
    RateLimit: sentinel.RateLimitConfig{
        Enabled: true,
        ByIP:    &sentinel.Limit{Requests: 100, Window: time.Minute},
        ByRoute: map[string]sentinel.Limit{
            // Auth endpoints deserve far tighter caps than everything else.
            "/api/auth/login":    {Requests: 5, Window: 15 * time.Minute},
            "/api/auth/register": {Requests: 3, Window: 15 * time.Minute},
        },
        ExcludeRoutes: excludedRoutes,
    },
 
    AuthShield: sentinel.AuthShieldConfig{
        Enabled:    true,
        LoginRoute: "/api/auth/login", // must match your actual route
    },
 
    Anomaly: sentinel.AnomalyConfig{Enabled: true},
    Geo:     sentinel.GeoConfig{Enabled: true},
}

Start in monitor mode

On an existing application, run with Mode: sentinel.ModeMonitor for a week first. It records what it would have blocked without blocking anything. Read the threat log, add exclusions for the false positives you find, and only then switch to ModeBlock. Going straight to blocking on a live app is how you discover that your page builder posts HTML — from your customers, loudly.


5. Five things that will bite you

These cost real debugging time. All five are confirmed against v2.2.1's source.

5.1 It refuses to start with default credentials

In gin.ReleaseMode, v2 returns ErrInsecureDefaults rather than booting with its built-in dashboard password. This is correct behaviour — a known password on a public security dashboard is worse than no dashboard — but it will fail your first production deploy if you haven't set real values.

Handle it deliberately rather than discovering it at 2am:

// Placeholder values you want to catch — Sentinel's own defaults plus whatever
// your .env.example ships with.
var placeholderPasswords = map[string]bool{
    "": true, "sentinel": true, "password": true, "changeme": true,
}
var placeholderSecrets = map[string]bool{
    "": true, "change-me": true, "sentinel-secret-change-me": true,
    "sentinel-default-secret-change-me": true,
}
 
func hasDefaultCredentials(password, secret string) bool {
    return placeholderPasswords[strings.ToLower(strings.TrimSpace(password))] ||
        placeholderSecrets[strings.ToLower(strings.TrimSpace(secret))]
}
 
mount := true
if hasDefaultCredentials(cfg.Dashboard.Password, cfg.Dashboard.SecretKey) {
    if isProduction {
        // Say exactly what to set. A generic "failed to start" here wastes an
        // afternoon.
        log.Println("[SECURITY] Sentinel NOT mounted: SENTINEL_PASSWORD / SENTINEL_SECRET_KEY are still placeholders.")
        log.Println("[SECURITY] The dashboard would be reachable with known credentials and forgeable JWTs.")
        log.Println("[SECURITY] Set both to real secrets and redeploy, or set SENTINEL_ENABLED=false.")
        mount = false
    } else {
        // Local development would be unusable otherwise.
        cfg.Dashboard.AllowInsecureDefaults = true
    }
}
 
if mount {
    if err := sentinel.MountE(r, db, cfg); err != nil {
        // A security subsystem failing to initialise should not take the whole
        // API down with it. Log loudly, serve without it.
        log.Printf("[SECURITY] Sentinel failed to mount, continuing without it: %v", err)
    }
}

5.2 Behind a proxy, every attacker looks like your load balancer

v2 ignores X-Forwarded-For and X-Real-IP unless the peer is in WAF.TrustedProxies. That default is right — trusting those headers blindly lets anyone spoof their IP and evade a ban — but if you leave it empty behind Nginx, Traefik, Dokploy or Cloudflare, every threat is attributed to the proxy. Your per-IP data is meaningless and an IP ban blocks your own proxy.

WAF: sentinel.WAFConfig{
    TrustedProxies: []string{"10.0.0.0/8", "172.16.0.0/12"},
}

Behind Cloudflare, add their published ranges: www.cloudflare.com/ips

5.3 Wildcards only work in v2

v1 compared ExcludeRoutes as exact strings, so every "/admin/*" entry was silent dead config — the routes were being inspected the whole time and nobody knew. v2 has a real matcher:

PatternMatches
/v1/*/v1 and one level below
/v1/**/v1 and everything below it, any depth
/api/*/editone path segment in the middle

If you're upgrading, re-read your exclusion list assuming none of it worked before. Routes you thought were excluded may have been inspected for months.

5.4 The default storage is a file that gets deleted

Without a Storage block, Sentinel writes to sentinel.db beside the binary. In a container that file is destroyed on every deploy. Point it at Postgres (§4) and your history survives.

5.5 Bans take about 30 seconds

Sentinel keeps an in-memory blocklist that re-syncs on a 30-second timer. A ban written directly to the store — which is what the console below does — starts being enforced within about half a minute, not instantly. Say so in your UI rather than letting an operator think the ban failed.


6. Reading Sentinel's data

Two options, and the second is almost always better.

Over HTTP. Sentinel exposes /sentinel/api/*, authenticated with a JWT from POST /sentinel/api/auth/login. This means your app holds dashboard credentials and makes an HTTP round trip to itself.

In-process (recommended). Sentinel's storage layer is exported. Its Postgres driver is its GORM store opened on a Postgres dialect, so you can wrap the *gorm.DB you already have and read the same sentinel_* tables through your existing connection pool:

import sentinelsqlite "github.com/MUKE-coder/sentinel/v2/storage/sqlite"
 
// Despite the package name, this works for any dialect — it wraps whatever
// *gorm.DB you hand it.
store := sentinelsqlite.NewFromGormDB(db)

No second pool, no JWT, no credentials in a browser. store satisfies storage.Store, which composes:

InterfaceUseful methods
ThreatStoreListThreats(ctx, ThreatFilter) ([]*ThreatEvent, int64, error), GetThreatStats(ctx, window)
IPStoreListBlockedIPs(ctx), BlockIP(ctx, ip, reason, expiry), UnblockIP(ctx, ip)
ActorStoreGetActor(ctx, ip), ListActors(ctx, filter)
ScoreStoreGetSecurityScore(ctx)
AuditStoreListAuditLogs(ctx, filter)
MetricStoreGetPerformanceOverview(ctx), GetRouteMetrics(ctx)
UserActivityStoreListUserActivity(ctx, userID, filter), ListUsers(ctx)
AnalyticsStoreGetAttackTrends, GetGeoStats, GetTopTargetssee below
LifecycleStoreMigrate(ctx), Cleanup(ctx, olderThan), Close()

The whole AnalyticsStore is stubbed in v2.2.1. GetAttackTrends, GetGeoStats and GetTopTargets all return []*T{}, nil — no query, no error, just an empty slice. A chart fed from them renders blank and nothing tells you why. Aggregate sentinel_threats yourself instead; §7.5 shows how.


7. Your own API layer

Sitting your own endpoints in front of Sentinel's data means the browser never touches Sentinel, and you can apply your app's own authorisation.

7.1 The handler

package handlers
 
import (
    "context"
    "net"
    "net/http"
    "strconv"
    "strings"
    "time"
 
    sentinelcore "github.com/MUKE-coder/sentinel/v2/core"
    sentinelstorage "github.com/MUKE-coder/sentinel/v2/storage"
    "github.com/gin-gonic/gin"
    "gorm.io/gorm"
)
 
// SecurityHandler serves the operator console at /api/security/*.
//
// Store is nil whenever Sentinel is disabled or failed to mount — every
// endpoint degrades to empty data rather than returning 500s, so the page
// still renders and says the subsystem is off.
type SecurityHandler struct {
    DB    *gorm.DB
    Store sentinelstorage.Store
}
 
// Prefix on every ban placed from this console, so the summary can tell
// operator bans apart from Sentinel's automatic ones.
const manualBanMarker = "[admin]"
 
const threatsTable = "sentinel_threats"
 
var rateLimitThreatType = string(sentinelcore.ThreatRateLimitExceeded)
 
func (h *SecurityHandler) available() bool { return h.Store != nil }
 
func securityError(c *gin.Context, status int, code, message string) {
    c.JSON(status, gin.H{"error": gin.H{"code": code, "message": message}})
}

7.2 Summary — the stat cards

type SecuritySummary struct {
    Enabled            bool   `json:"enabled"`
    BannedIPs          int64  `json:"banned_ips"`
    AutoBans24h        int64  `json:"auto_bans_24h"`
    ManualBans24h      int64  `json:"manual_bans_24h"`
    RateLimitedIPs1h   int64  `json:"rate_limited_ips_1h"`
    RateLimitedIPs5m   int64  `json:"rate_limited_ips_5m"`
    Threats24h         int64  `json:"threats_24h"`
    CriticalThreats24h int64  `json:"critical_threats_24h"`
    Score              int    `json:"score"`
    Grade              string `json:"grade"`
}
 
func (h *SecurityHandler) Summary(c *gin.Context) {
    summary := SecuritySummary{Enabled: h.available(), Grade: "-"}
    if !h.available() {
        c.JSON(http.StatusOK, gin.H{"data": summary})
        return
    }
 
    ctx := c.Request.Context()
    now := time.Now()
 
    if blocked, err := h.Store.ListBlockedIPs(ctx); err == nil {
        summary.BannedIPs = int64(len(blocked))
        cutoff := now.Add(-24 * time.Hour)
        for _, b := range blocked {
            if b.BlockedAt.Before(cutoff) {
                continue
            }
            if strings.HasPrefix(b.Reason, manualBanMarker) {
                summary.ManualBans24h++
            } else {
                summary.AutoBans24h++
            }
        }
    }
 
    summary.RateLimitedIPs1h = h.countRateLimitedIPs(ctx, time.Hour)
    summary.RateLimitedIPs5m = h.countRateLimitedIPs(ctx, 5*time.Minute)
 
    if stats, err := h.Store.GetThreatStats(ctx, 24*time.Hour); err == nil && stats != nil {
        summary.Threats24h = stats.TotalThreats
        summary.CriticalThreats24h = stats.CriticalCount
    }
 
    // The score engine recomputes on a background timer, so there's no row for
    // the first few minutes after a cold start.
    if score, err := h.Store.GetSecurityScore(ctx); err == nil && score != nil {
        summary.Score = score.Overall
        summary.Grade = score.Grade
    }
 
    c.JSON(http.StatusOK, gin.H{"data": summary})
}
 
func (h *SecurityHandler) countRateLimitedIPs(ctx context.Context, window time.Duration) int64 {
    var count int64
    err := h.DB.WithContext(ctx).
        Table(threatsTable).
        Where("threat_types LIKE ?", "%"+rateLimitThreatType+"%").
        Where("timestamp >= ?", time.Now().Add(-window)).
        Distinct("ip").
        Count(&count).Error
    if err != nil {
        return 0
    }
    return count
}

7.3 Threats — the paginated table

type SecurityThreat struct {
    ID          string    `json:"id"`
    Timestamp   time.Time `json:"timestamp"`
    IP          string    `json:"ip"`
    Method      string    `json:"method"`
    Path        string    `json:"path"`
    StatusCode  int       `json:"status_code"`
    ThreatTypes []string  `json:"threat_types"`
    Severity    string    `json:"severity"`
    Blocked     bool      `json:"blocked"`
    Country     string    `json:"country,omitempty"`
    Confidence  float64   `json:"confidence"`
}
 
func (h *SecurityHandler) Threats(c *gin.Context) {
    page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
    pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
    if page < 1 {
        page = 1
    }
    if pageSize < 1 || pageSize > 100 {
        pageSize = 20
    }
 
    if !h.available() {
        c.JSON(http.StatusOK, gin.H{
            "data": []SecurityThreat{},
            "meta": gin.H{"total": 0, "page": page, "page_size": pageSize, "enabled": false},
        })
        return
    }
 
    filter := sentinelcore.ThreatFilter{
        Severity: sentinelcore.Severity(strings.TrimSpace(c.Query("severity"))),
        Type:     strings.TrimSpace(c.Query("type")),
        IP:       strings.TrimSpace(c.Query("ip")),
        Search:   strings.TrimSpace(c.Query("search")),
        Page:     page,
        PageSize: pageSize,
    }
 
    events, total, err := h.Store.ListThreats(c.Request.Context(), filter)
    if err != nil {
        securityError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to read threat history")
        return
    }
 
    // Trim to what the table renders. The raw event also carries headers, body
    // snippets and match evidence — useful for a detail view, too much for a list.
    threats := make([]SecurityThreat, 0, len(events))
    for _, e := range events {
        threats = append(threats, SecurityThreat{
            ID: e.ID, Timestamp: e.Timestamp, IP: e.IP,
            Method: e.Method, Path: e.Path, StatusCode: e.StatusCode,
            ThreatTypes: e.ThreatTypes, Severity: string(e.Severity),
            Blocked: e.Blocked, Country: e.Country, Confidence: e.Confidence,
        })
    }
 
    c.JSON(http.StatusOK, gin.H{
        "data": threats,
        "meta": gin.H{"total": total, "page": page, "page_size": pageSize, "enabled": true},
    })
}

7.4 Banning and unbanning

type securityBlockIPRequest struct {
    IP       string `json:"ip" binding:"required"`
    Reason   string `json:"reason"`
    Duration string `json:"duration"` // Go duration, e.g. "24h". Empty = permanent.
}
 
func (h *SecurityHandler) BlockIP(c *gin.Context) {
    var req securityBlockIPRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        securityError(c, http.StatusUnprocessableEntity, "VALIDATION_ERROR", err.Error())
        return
    }
    if !h.available() {
        securityError(c, http.StatusServiceUnavailable, "SENTINEL_UNAVAILABLE",
            "Sentinel is not running — IP bans are unavailable")
        return
    }
 
    ip := strings.TrimSpace(req.IP)
    if !isIPOrCIDR(ip) {
        securityError(c, http.StatusUnprocessableEntity, "VALIDATION_ERROR",
            "Enter a valid IP address or CIDR range")
        return
    }
 
    var expiry *time.Time
    if d := strings.TrimSpace(req.Duration); d != "" {
        parsed, err := time.ParseDuration(d)
        if err != nil || parsed <= 0 {
            securityError(c, http.StatusUnprocessableEntity, "VALIDATION_ERROR",
                "Duration must be a positive Go duration such as 24h or 30m")
            return
        }
        until := time.Now().Add(parsed)
        expiry = &until
    }
 
    reason := strings.TrimSpace(req.Reason)
    if reason == "" {
        reason = "Blocked from the security console"
    }
 
    if err := h.Store.BlockIP(c.Request.Context(), ip, manualBanMarker+" "+reason, expiry); err != nil {
        securityError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to ban the IP")
        return
    }
 
    // Tell the truth about the 30-second cache (§5.5).
    c.JSON(http.StatusCreated, gin.H{
        "data":    gin.H{"ip": ip, "expires_at": expiry},
        "message": ip + " banned — enforcement starts within 30 seconds",
    })
}
 
func (h *SecurityHandler) UnblockIP(c *gin.Context) {
    if !h.available() {
        securityError(c, http.StatusServiceUnavailable, "SENTINEL_UNAVAILABLE",
            "Sentinel is not running — IP bans are unavailable")
        return
    }
    ip := strings.TrimSpace(c.Param("ip"))
    if err := h.Store.UnblockIP(c.Request.Context(), ip); err != nil {
        securityError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to lift the ban")
        return
    }
    c.JSON(http.StatusOK, gin.H{"data": gin.H{"ip": ip}, "message": ip + " unbanned"})
}
 
// Accepts both a bare address and a CIDR range.
func isIPOrCIDR(value string) bool {
    if net.ParseIP(value) != nil {
        return true
    }
    _, _, err := net.ParseCIDR(value)
    return err == nil
}

GetAttackTrends returns nothing in v2.2.1, so query the table directly:

type SecurityTrendPoint struct {
    Period   string `json:"period"`
    Total    int64  `json:"total"`
    Blocked  int64  `json:"blocked"`
    Critical int64  `json:"critical"`
}
 
func (h *SecurityHandler) Trends(c *gin.Context) {
    window := parseWindow(c.Query("window"), 24*time.Hour)
    bucket := "hour"
    if window > 72*time.Hour {
        bucket = "day" // hourly buckets over a month is 720 points; nobody reads that
    }
 
    points := []SecurityTrendPoint{}
    if !h.available() {
        c.JSON(http.StatusOK, gin.H{"data": points, "meta": gin.H{"enabled": false}})
        return
    }
 
    var agg []struct {
        Period   time.Time
        Total    int64
        Blocked  int64
        Critical int64
    }
 
    err := h.DB.WithContext(c.Request.Context()).
        Table(threatsTable).
        // bucket is one of two literals chosen above, never user input, so
        // inlining is safe — and necessary, because Postgres cannot infer a
        // parameter type for date_trunc's first argument when it is bound.
        Select("date_trunc('"+bucket+"', timestamp) AS period, COUNT(*) AS total, "+
            "COUNT(*) FILTER (WHERE blocked) AS blocked, "+
            "COUNT(*) FILTER (WHERE severity = 'Critical') AS critical").
        Where("timestamp >= ?", time.Now().Add(-window)).
        Group("period").
        Order("period ASC").
        Scan(&agg).Error
    if err != nil {
        c.JSON(http.StatusOK, gin.H{"data": points, "meta": gin.H{"enabled": true}})
        return
    }
 
    layout := time.RFC3339
    if bucket == "day" {
        layout = "2006-01-02"
    }
    for _, a := range agg {
        points = append(points, SecurityTrendPoint{
            Period: a.Period.Format(layout), Total: a.Total,
            Blocked: a.Blocked, Critical: a.Critical,
        })
    }
 
    c.JSON(http.StatusOK, gin.H{
        "data": points,
        "meta": gin.H{"window": window.String(), "interval": bucket, "enabled": true},
    })
}
 
// Clamped so a hand-crafted ?window=100000h can't table-scan your history.
func parseWindow(raw string, fallback time.Duration) time.Duration {
    if raw == "" {
        return fallback
    }
    d, err := time.ParseDuration(raw)
    if err != nil || d <= 0 {
        return fallback
    }
    if d > 720*time.Hour {
        return 720 * time.Hour
    }
    return d
}

COUNT(*) FILTER (WHERE ...) is Postgres. On MySQL use SUM(blocked = 1), and replace date_trunc with DATE_FORMAT.

7.6 Wiring the routes

Put these behind your admin authorisation, not just authentication. Threat data reveals your attack surface.

securityHandler := &handlers.SecurityHandler{DB: db, Store: sentinelStore}
 
admin := r.Group("/api")
admin.Use(middleware.Auth(db))
{
    admin.GET("/security/summary",        middleware.RequireAdmin(), securityHandler.Summary)
    admin.GET("/security/threats",        middleware.RequireAdmin(), securityHandler.Threats)
    admin.GET("/security/rate-limits",    middleware.RequireAdmin(), securityHandler.RateLimited)
    admin.GET("/security/trends",         middleware.RequireAdmin(), securityHandler.Trends)
    admin.GET("/security/blocked-ips",    middleware.RequireAdmin(), securityHandler.BlockedIPs)
    admin.POST("/security/blocked-ips",   middleware.RequireAdmin(), securityHandler.BlockIP)
    admin.DELETE("/security/blocked-ips/:ip", middleware.RequireAdmin(), securityHandler.UnblockIP)
}

8. The dashboard: data hooks

React with TanStack Query. Adapt freely — the shapes are what matter.

// hooks/use-security.ts
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { apiClient } from "@/lib/api-client";
 
export interface SecuritySummary {
  enabled: boolean;
  banned_ips: number;
  auto_bans_24h: number;
  manual_bans_24h: number;
  rate_limited_ips_1h: number;
  rate_limited_ips_5m: number;
  threats_24h: number;
  critical_threats_24h: number;
  score: number;
  grade: string;
}
 
export interface SecurityThreat {
  id: string;
  timestamp: string;
  ip: string;
  method: string;
  path: string;
  status_code: number;
  threat_types: string[] | null;
  severity: string;
  blocked: boolean;
  country?: string;
  confidence: number;
}
 
export interface SecurityBlockedIP {
  ip: string;
  reason: string;
  source: "manual" | "auto";
  blocked_at: string;
  expires_at?: string | null;
  cidr: boolean;
}
 
function errMessage(error: unknown, fallback: string) {
  const e = error as { response?: { data?: { error?: { message?: string } } } };
  return e?.response?.data?.error?.message || fallback;
}
 
// A security view is only useful if it's current — but 30s is frequent enough
// to feel live without hammering the database.
export function useSecuritySummary() {
  return useQuery<SecuritySummary>({
    queryKey: ["security", "summary"],
    queryFn: async () =>
      (await apiClient.get("/api/security/summary")).data.data,
    refetchInterval: 30_000,
  });
}
 
export function useSecurityThreats(
  filters: Record<string, string | number | undefined>
) {
  return useQuery<{ threats: SecurityThreat[]; total: number }>({
    queryKey: ["security", "threats", filters],
    queryFn: async () => {
      const params = new URLSearchParams();
      Object.entries(filters).forEach(([k, v]) => {
        if (v !== undefined && v !== null && v !== "") params.set(k, String(v));
      });
      const { data } = await apiClient.get(`/api/security/threats?${params}`);
      return { threats: data.data ?? [], total: data.meta?.total ?? 0 };
    },
    // Keeps the previous page visible while the next loads, so the table
    // doesn't blank out on every filter change.
    placeholderData: (previous) => previous,
  });
}
 
export function useSecurityBlockedIPs() {
  return useQuery<SecurityBlockedIP[]>({
    queryKey: ["security", "blocked-ips"],
    queryFn: async () =>
      (await apiClient.get("/api/security/blocked-ips")).data.data ?? [],
    refetchInterval: 30_000,
  });
}
 
export function useSecurityTrends(window = "24h") {
  return useQuery({
    queryKey: ["security", "trends", window],
    queryFn: async () =>
      (await apiClient.get(`/api/security/trends?window=${window}`)).data
        .data ?? [],
    refetchInterval: 60_000,
  });
}
 
export function useBlockIP() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: async (input: {
      ip: string;
      reason?: string;
      duration?: string;
    }) => (await apiClient.post("/api/security/blocked-ips", input)).data,
    onSuccess: (data: { message: string }) => {
      queryClient.invalidateQueries({ queryKey: ["security"] });
      toast.success(data.message); // carries the 30-second caveat
    },
    onError: (error) => toast.error(errMessage(error, "Failed to ban the IP")),
  });
}
 
export function useUnblockIP() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: async (ip: string) =>
      (
        await apiClient.delete(
          `/api/security/blocked-ips/${encodeURIComponent(ip)}`
        )
      ).data,
    onSuccess: (data: { message: string }) => {
      queryClient.invalidateQueries({ queryKey: ["security"] });
      toast.success(data.message);
    },
    onError: (error) =>
      toast.error(errMessage(error, "Failed to lift the ban")),
  });
}

9. The dashboard: UI

A security console is scanned, not read. Someone opens it because something feels wrong, and they need the answer in seconds. Three rules:

  1. Summary before detail. Counts at the top, history below.
  2. Encode state in shape as well as number. A severity pill is read faster than the word "critical" in a cell.
  3. Empty states are the normal state. "No IPs are currently banned" is what a healthy system looks like — make it calm, not alarming.

9.1 Severity pills

const SEVERITY_STYLES: Record<string, string> = {
  critical: "bg-red-500/10 text-red-600 dark:text-red-400",
  high: "bg-orange-500/10 text-orange-600 dark:text-orange-400",
  medium: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
  low: "bg-slate-500/10 text-slate-600 dark:text-slate-400",
};
 
function SeverityPill({ severity }: { severity: string }) {
  const key = severity.toLowerCase();
  return (
    <span
      className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-semibold tracking-wide uppercase ${
        SEVERITY_STYLES[key] ?? SEVERITY_STYLES.low
      }`}
    >
      {severity}
    </span>
  );
}

9.2 Stat cards

function StatCard({
  label,
  value,
  hint,
  icon: Icon,
  tone = "default",
}: {
  label: string;
  value: number | string;
  hint?: string;
  icon: React.ComponentType<{ className?: string }>;
  tone?: "default" | "warning" | "danger";
}) {
  const toneClass = {
    default: "text-text-muted",
    warning: "text-amber-500",
    danger: "text-red-500",
  }[tone];
 
  return (
    <div className="bg-bg-secondary rounded-xl border border-border p-5">
      <div className="flex items-start justify-between gap-3">
        <p className="text-text-muted text-xs font-semibold tracking-wide uppercase">
          {label}
        </p>
        <Icon className={`h-4 w-4 shrink-0 ${toneClass}`} />
      </div>
      <p className="mt-3 text-3xl font-bold text-foreground tabular-nums">
        {value}
      </p>
      {hint && <p className="text-text-muted mt-1 text-xs">{hint}</p>}
    </div>
  );
}

9.3 The page

"use client";
 
import { useState } from "react";
import {
  useSecuritySummary,
  useSecurityThreats,
  useSecurityBlockedIPs,
  useSecurityTrends,
  useBlockIP,
  useUnblockIP,
} from "@/hooks/use-security";
import { Ban, Shield, Zap, ExternalLink, Plus, Trash2 } from "lucide-react";
import {
  Area,
  AreaChart,
  ResponsiveContainer,
  Tooltip,
  XAxis,
  YAxis,
} from "recharts";
 
/** "1d 8h ago" reads faster than a timestamp when you're scanning. */
function timeAgo(iso: string): string {
  const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
  if (seconds < 60) return "just now";
  const minutes = Math.floor(seconds / 60);
  if (minutes < 60) return `${minutes}m ago`;
  const hours = Math.floor(minutes / 60);
  if (hours < 24) return `${hours}h ${minutes % 60}m ago`;
  return `${Math.floor(hours / 24)}d ${hours % 24}h ago`;
}
 
export default function SecurityPage() {
  const { data: summary } = useSecuritySummary();
  const { data: bans } = useSecurityBlockedIPs();
  const { data: trends } = useSecurityTrends("24h");
  const [page, setPage] = useState(1);
  const [severity, setSeverity] = useState("");
  const { data: threats } = useSecurityThreats({
    page,
    page_size: 20,
    severity,
  });
 
  const { mutate: blockIP } = useBlockIP();
  const { mutate: unblockIP } = useUnblockIP();
  const [showBanModal, setShowBanModal] = useState(false);
 
  return (
    <div className="space-y-6 p-8">
      <header className="flex flex-wrap items-start justify-between gap-4">
        <div>
          <h1 className="text-2xl font-bold text-foreground">Security</h1>
          <p className="text-text-secondary mt-1 text-sm">
            Rate-limit pressure, IP bans, and recent threats — powered by
            Sentinel
          </p>
        </div>
        <a
          href={`${process.env.NEXT_PUBLIC_API_URL}/sentinel`}
          target="_blank"
          rel="noopener noreferrer"
          className="hover:bg-bg-hover inline-flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium"
        >
          Open full Sentinel <ExternalLink className="h-4 w-4" />
        </a>
      </header>
 
      {summary && !summary.enabled && (
        <p className="rounded-lg border border-amber-500/30 bg-amber-500/[0.08] px-4 py-3 text-sm">
          Sentinel isn&apos;t running, so there&apos;s nothing to show. Check
          SENTINEL_ENABLED and the startup logs.
        </p>
      )}
 
      <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
        <StatCard
          label="Currently banned IPs"
          value={summary?.banned_ips ?? 0}
          icon={Ban}
          tone={summary?.banned_ips ? "danger" : "default"}
          hint={`${summary?.manual_bans_24h ?? 0} placed by an operator in the last 24h`}
        />
        <StatCard
          label="Auto-bans (last 24h)"
          value={summary?.auto_bans_24h ?? 0}
          icon={Shield}
          hint={`${summary?.threats_24h ?? 0} threats detected in the same window`}
        />
        <StatCard
          label="Rate-limited IPs (last hour)"
          value={summary?.rate_limited_ips_1h ?? 0}
          icon={Zap}
          tone={summary?.rate_limited_ips_1h ? "warning" : "default"}
          hint={`${summary?.rate_limited_ips_5m ?? 0} in the last 5 minutes`}
        />
      </div>
 
      {/* Trend chart */}
      {trends && trends.length > 0 && (
        <section className="bg-bg-secondary rounded-xl border border-border p-5">
          <h2 className="text-text-muted text-xs font-semibold tracking-wide uppercase">
            Threats per hour — last 24h
          </h2>
          <div className="mt-4 h-48">
            <ResponsiveContainer width="100%" height="100%">
              <AreaChart data={trends}>
                <defs>
                  <linearGradient id="threats" x1="0" y1="0" x2="0" y2="1">
                    <stop
                      offset="0%"
                      stopColor="var(--accent)"
                      stopOpacity={0.3}
                    />
                    <stop
                      offset="100%"
                      stopColor="var(--accent)"
                      stopOpacity={0}
                    />
                  </linearGradient>
                </defs>
                <XAxis
                  dataKey="period"
                  tick={{ fontSize: 10 }}
                  stroke="var(--text-muted)"
                  tickFormatter={(p: string) => p.slice(11, 16)}
                />
                <YAxis
                  allowDecimals={false}
                  tick={{ fontSize: 10 }}
                  stroke="var(--text-muted)"
                  width={28}
                />
                <Tooltip
                  contentStyle={{
                    background: "var(--bg-elevated)",
                    border: "1px solid var(--border)",
                    borderRadius: 8,
                    fontSize: 12,
                  }}
                />
                <Area
                  type="monotone"
                  dataKey="total"
                  stroke="var(--accent)"
                  fill="url(#threats)"
                  strokeWidth={2}
                />
              </AreaChart>
            </ResponsiveContainer>
          </div>
        </section>
      )}
 
      {/* Active bans */}
      <section className="bg-bg-secondary rounded-xl border border-border">
        <div className="flex items-center justify-between border-b border-border px-5 py-4">
          <h2 className="text-text-muted text-xs font-semibold tracking-wide uppercase">
            Active IP bans
          </h2>
          <button
            onClick={() => setShowBanModal(true)}
            className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-sm font-medium text-white"
          >
            <Plus className="h-4 w-4" /> Ban an IP
          </button>
        </div>
 
        {(bans?.length ?? 0) === 0 ? (
          <p className="text-text-muted px-5 py-12 text-center text-sm">
            No IPs are currently banned.
          </p>
        ) : (
          <div className="overflow-x-auto">
            <table className="w-full min-w-[640px] text-sm">
              <thead>
                <tr className="text-text-muted border-b border-border text-left text-xs tracking-wide uppercase">
                  <th className="px-5 py-2.5">IP</th>
                  <th className="px-5 py-2.5">Reason</th>
                  <th className="px-5 py-2.5">Banned</th>
                  <th className="px-5 py-2.5">Expires</th>
                  <th className="px-5 py-2.5 text-right">Action</th>
                </tr>
              </thead>
              <tbody className="divide-y divide-border">
                {bans?.map((ban) => (
                  <tr key={ban.ip}>
                    <td className="px-5 py-3 font-mono text-xs">{ban.ip}</td>
                    <td className="text-text-secondary px-5 py-3">
                      {ban.reason}
                    </td>
                    <td className="text-text-muted px-5 py-3 text-xs">
                      {timeAgo(ban.blocked_at)}
                    </td>
                    <td className="text-text-muted px-5 py-3 text-xs">
                      {ban.expires_at ? timeAgo(ban.expires_at) : "Never"}
                    </td>
                    <td className="px-5 py-3 text-right">
                      <button
                        onClick={() => unblockIP(ban.ip)}
                        className="text-text-muted rounded-lg p-1.5 hover:text-red-500"
                        title="Lift this ban"
                      >
                        <Trash2 className="h-4 w-4" />
                      </button>
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        )}
      </section>
 
      {/* Recent threats */}
      <section className="bg-bg-secondary rounded-xl border border-border">
        <div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-5 py-4">
          <h2 className="text-text-muted text-xs font-semibold tracking-wide uppercase">
            Recent threats
          </h2>
          <select
            value={severity}
            onChange={(e) => {
              setSeverity(e.target.value);
              setPage(1);
            }}
            className="rounded-lg border border-border bg-background px-3 py-1.5 text-sm"
          >
            <option value="">All severities</option>
            <option value="Critical">Critical</option>
            <option value="High">High</option>
            <option value="Medium">Medium</option>
            <option value="Low">Low</option>
          </select>
        </div>
 
        {(threats?.threats.length ?? 0) === 0 ? (
          <p className="text-text-muted px-5 py-12 text-center text-sm">
            Nothing recorded in this window.
          </p>
        ) : (
          <div className="overflow-x-auto">
            <table className="w-full min-w-[760px] text-sm">
              <thead>
                <tr className="text-text-muted border-b border-border text-left text-xs tracking-wide uppercase">
                  <th className="px-5 py-2.5">When</th>
                  <th className="px-5 py-2.5">IP</th>
                  <th className="px-5 py-2.5">Method</th>
                  <th className="px-5 py-2.5">Path</th>
                  <th className="px-5 py-2.5">Types</th>
                  <th className="px-5 py-2.5">Severity</th>
                </tr>
              </thead>
              <tbody className="divide-y divide-border">
                {threats?.threats.map((t) => (
                  <tr key={t.id}>
                    <td className="text-text-muted px-5 py-3 text-xs">
                      {timeAgo(t.timestamp)}
                    </td>
                    <td className="px-5 py-3 font-mono text-xs">{t.ip}</td>
                    <td className="px-5 py-3 font-mono text-xs">{t.method}</td>
                    <td className="max-w-[240px] truncate px-5 py-3 font-mono text-xs">
                      {t.path}
                    </td>
                    <td className="px-5 py-3 text-xs">
                      {(t.threat_types ?? []).join(", ")}
                    </td>
                    <td className="px-5 py-3">
                      <SeverityPill severity={t.severity} />
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        )}
      </section>
    </div>
  );
}

The ban modal is a plain form posting { ip, reason, duration } to useBlockIP(). Mention in its copy that enforcement starts within 30 seconds.


10. Environment variables

# Set to false to disable Sentinel entirely.
SENTINEL_ENABLED=true
 
# Dashboard login. In production the app refuses to mount Sentinel while these
# are placeholders — the dashboard would be reachable with known credentials.
SENTINEL_USERNAME=admin
SENTINEL_PASSWORD=                 # openssl rand -hex 32
SENTINEL_SECRET_KEY=               # openssl rand -hex 32 — signs dashboard JWTs
 
# Comma-separated IPs/CIDRs of the reverse proxies in front of the API.
# Without this, every threat is attributed to the proxy rather than the real
# client, and an IP ban blocks your own load balancer.
# Cloudflare ranges: https://www.cloudflare.com/ips/
SENTINEL_TRUSTED_PROXIES=          # e.g. 10.0.0.0/8,172.16.0.0/12

11. Verifying it works

Confirm it's actually inspecting traffic, rather than assuming:

# 1. A SQL-injection probe. Expect 403 in ModeBlock.
curl -i "http://localhost:8080/api/anything?id=1'%20OR%20'1'='1"
 
# 2. An XSS probe.
curl -i "http://localhost:8080/api/anything?q=<script>alert(1)</script>"
 
# 3. Rate limiting — the 6th login attempt should be refused.
for i in $(seq 1 6); do
  curl -s -o /dev/null -w "%{http_code}\n" \
    -X POST http://localhost:8080/api/auth/login \
    -H 'Content-Type: application/json' \
    -d '{"email":"a@b.c","password":"wrong"}'
done
 
# 4. Your own console should now show them.
curl -s http://localhost:8080/api/security/summary -H "Authorization: Bearer $TOKEN" | jq

Then check an excluded route is genuinely excluded — this is the step people skip, and it's where the v1 wildcard bug hid for months:

curl -i -X POST http://localhost:8080/api/website/pages/1 \
  -H 'Content-Type: application/json' \
  -d '{"content":"<script>legitimate editor content</script>"}'
# Expect your app's own response, not a 403 from the WAF.

12. Troubleshooting

SymptomCauseFix
App won't start, ErrInsecureDefaultsDefault password/secret in release modeSet real SENTINEL_PASSWORD and SENTINEL_SECRET_KEY (§5.1)
Every threat shows the same IPTrustedProxies unset behind a proxy§5.2
Legitimate requests blockedWAF rules matching real payloadsAdd the route to ExcludeRoutes; start in ModeMonitor
Threat history empties on deployDefault SQLite storage in a containerConfigure Postgres storage (§5.4)
Ban doesn't take effect immediately30-second in-memory cacheExpected; say so in the UI (§5.5)
ExcludeRoutes seems ignoredStill on v1, which exact-matchesUpgrade to v2 (§5.3)
Trend / geo / top-target data always emptyThe whole AnalyticsStore is stubbedAggregate the table yourself (§7.5)
Dashboard 404sMounted after your routesMount Sentinel before registering routes (§3)
Chrome/Edge users blocked (v2.0.x)An SSRF rule matched 0.0.0.0 inside UA version stringsUpgrade to v2.1.0+

Appendix: schema

The tables Sentinel creates, useful when writing your own aggregates:

TableHolds
sentinel_threatsEvery detection: ip, method, path, threat_types, severity, blocked, timestamp, country, confidence, plus headers and body snippets
sentinel_blocked_ipsActive bans: ip, reason, blocked_at, expires_at
sentinel_actorsPer-IP reputation: first/last seen, totals, attack types, risk score
sentinel_audit_logsDashboard actions
sentinel_metricsPerformance samples

Two notes when querying directly: threat_types is stored as a serialised list, so match it with LIKE '%type%' rather than equality; and severity is capitalised (Critical, not critical).


Written against Sentinel v2.2.1. Behaviour described here was verified against the library's source rather than its README — the two disagree on several endpoint paths.