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.
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.
Mounting Sentinel gives you five subsystems as middleware, plus a database of
everything they saw:
Subsystem
What it does
WAF
Inspects each request for SQL injection, XSS, path traversal, SSRF and command injection. Blocks or logs.
Rate limiting
Per-IP and per-route request caps with configurable windows.
Auth shield
Watches your login route for credential stuffing and brute force, and locks out offenders.
Anomaly detection
Flags traffic that deviates from the norm for a client.
Geo
Attaches 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.
Sentinel v2 uses a /v2 module path, so it can sit alongside v1 during a
migration:
go get github.com/MUKE-coder/sentinel/v2@latestgo 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
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},}
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.
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 := trueif 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) }}
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.
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:
Pattern
Matches
/v1/*
/v1 and one level below
/v1/**
/v1 and everything below it, any depth
/api/*/edit
one 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.
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.
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.
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:
GetAttackTrends, GetGeoStats, GetTopTargets — see below
LifecycleStore
Migrate(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.
package handlersimport ( "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}})}
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}
# 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=adminSENTINEL_PASSWORD= # openssl rand -hex 32SENTINEL_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
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.
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.