Security Guide
How Grit defends every category of the OWASP Top 10:2025 by default, with the file path and code for each. Use this page as the audit checklist when a client asks "are we secure?" — walk it with them line by line.
Secure by default. Every fresh grit new project ships with the controls below already wired. You don't add security — you opt out of pieces you don't want.
Threat model in one paragraph
Grit assumes a hostile public internet. The boundary of trust is the handler signature: anything coming in over the wire — body, query, header, cookie, even a JWT — is untrusted until verified. The defence pattern is layered (PHASE 2 §4.1 "defence in depth") — Sentinel WAF + rate limit + auth middleware + AuthZ check + parameterised queries + output encoding. A single failed layer never turns into a breach.
OWASP Top 10:2025 — defence by category
The 2025 edition is the canonical risk map (8th edition, finalised January 2026). For every category below, Grit ships the defence in code — not in documentation that asks you to remember.
A01 — Broken Access Control
Users acting beyond their permissions; now includes SSRF. Still #1.
Grit ships the internal/authz package as the canonical IDOR defence. Every object access goes through authz.MustOwn, which loads the row by ID, verifies it belongs to the authenticated user, and returns 404 (never 403) so existence isn't leaked through error-message differences.
func (h *InvoiceHandler) GetByID(c *gin.Context) {var invoice models.Invoiceif err := authz.MustOwn(c, h.DB, &invoice, c.Param("id")); err != nil {return // helper already wrote 404}c.JSON(http.StatusOK, gin.H{"data": invoice})}// The model implements Ownable:func (i *Invoice) GetOwnerID() string { return i.UserID }
For tenant / team scoping use authz.CheckScope. For admin-only routes use authz.RequireRoles("admin"). SSRF (absorbed into A01 in 2025) is handled by the internal/safefetch package — see A05 below.
A02 — Security Misconfiguration
Insecure defaults, open buckets, verbose errors. Up from #5.
The SecurityHeaders middleware sets all the OWASP-recommended headers by default: HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, Cross-Origin-Opener-Policy, Cross-Origin-Resource-Policy, and a strict Content-Security-Policy that blocks inline script.
func SecurityHeaders() gin.HandlerFunc {return func(c *gin.Context) {c.Header("X-Content-Type-Options", "nosniff")c.Header("X-Frame-Options", "DENY")c.Header("Referrer-Policy", "strict-origin-when-cross-origin")c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()")c.Header("Cross-Origin-Opener-Policy", "same-origin")c.Header("Cross-Origin-Resource-Policy", "same-origin")c.Header("Content-Security-Policy","default-src 'self'; script-src 'self'; ...; frame-ancestors 'none'; "+"base-uri 'self'; form-action 'self'; object-src 'none'")if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" {c.Header("Strict-Transport-Security","max-age=63072000; includeSubDomains; preload")}c.Next()}}
Error responses use a generic shape (code, message) that never includes a stack trace in production. Sensitive defaults: in production mode Sentinel WAF runs in block rather than log mode.
A03 — Software Supply Chain Failures
NEW in 2025 — compromised dependencies, build systems, distribution.
Every Grit project ships with .github/dependabot.yml configured for Go modules, npm, and GitHub Actions on a weekly schedule, plus .github/workflows/security.yml running:
- govulncheck — Go vulnerability scan against the Go vulnerability database.
- pnpm audit — high+ severity gate on the frontend.
- CodeQL — static analysis for both Go and JavaScript on every PR.
These run on every push and pull-request, plus weekly so newly-disclosed CVEs surface even when nothing in your code changed. Go modules are pinned in go.sum with checksum verification; pnpm uses --frozen-lockfile in CI.
A04 — Cryptographic Failures
Weak/missing encryption exposing sensitive data.
Three layers of defence ship by default:
- Passwords — bcrypt via
golang.org/x/crypto/bcryptwith the library default cost. Constant-time verify. Never SHA-256, never MD5, never reversible encryption. - Transport — HSTS preload enforced via SecurityHeaders. Any HTTPS request locks browsers into HTTPS-only for 2 years.
- JWT signing — HS256 with a 32-character minimum
JWT_SECRETenforced at startup. The auth middleware verifies the algorithm explicitly so the classicalg:noneattack is closed.
Secrets live in environment variables, never in source. .env is in .gitignore; the scaffold ships only .env.example.
A05 — Injection
SQLi, command injection, XSS — hostile input executed.
SQLi is closed at the protocol level: GORM uses parameterised queries for every Where, First, Create, Update, etc. The Grit codebase never builds queries by string concatenation; if you must use db.Raw, pass arguments as parameters, not interpolated strings.
// OK — parameteriseddb.Where("email = ?", email).First(&user)db.Raw("SELECT * FROM users WHERE email = ?", email).Scan(&user)// NEVER do thisdb.Raw("SELECT * FROM users WHERE email = '" + email + "'")
XSS — the React SPA escapes by default. The CSP header (A02) adds a second layer: even if something slips through, the browser refuses to execute inline script. dangerouslySetInnerHTML is intentionally banned by ESLint in scaffolded projects.
SSRF (folded into A01 in 2025) — use the internal/safefetch package for any HTTP request whose URL came from the caller (webhooks, image-from-URL, PDF render, OEmbed):
import "{module}}/internal/safefetch"resp, err := safefetch.Get(ctx, userProvidedURL)if errors.Is(err, safefetch.ErrBlocked) {return fmt.Errorf("URL not allowed: %w", err)}
safefetch blocks the standard SSRF targets — loopback (127.0.0.1, ::1), RFC1918 private ranges, link-local, CGNAT (100.64/10), AWS metadata (169.254.169.254 + IPv6 fd00:ec2::/32), and well-known cloud-metadata hostnames. It also re-validates the resolved IP at TCP-connect time via a custom dialer, closing the DNS-rebinding TOCTOU window.
A06 — Insecure Design
Flaws baked into the architecture, not the code.
Grit's architecture is opinionated specifically to make insecure designs hard to express: a single auth middleware that's applied to a whole route group (no per-handler oversight), a single Services struct that owns DB access (no rogue connections), and a code generator that wires every new resource through the same handler / service / route pattern with auth checks pre-applied.
When you generate a resource with grit add model (or generate), the produced handlers already call authz.MustOwn on every object access — IDOR is closed by the generator, not by the developer remembering.
A07 — Authentication Failures
Weak login, sessions, credential handling.
- Rate limiting on
/api/auth/login— 5 attempts per 15 min per IP via Sentinel. - Account-level brute-force protection — Sentinel's AuthShield locks the account after repeated failures.
- Short access tokens (15 min) paired with longer-lived refresh tokens (7 days). The frontend ships a
SessionExpiryMonitorthat refreshes transparently. - JWT algorithm pinning — the verifier rejects any token whose
algisn't the one we signed with.alg:noneis impossible. - TOTP 2FA shipped out of the box (
internal/totp) with trusted-device fingerprinting. - Password reset uses single-use, time-bound tokens.
A08 — Software & Data Integrity Failures
Trusting unverified code/data updates.
- Webhook signatures — the
internal/webhooksframework verifies HMAC signatures (Stripe, GitHub, Twilio, generic) before any business logic runs. - Idempotency-Key middleware — caches the response on the first non-safe request and replays it on retries, preventing duplicate writes from at-least-once delivery.
- Activity-log hash chain — every mutation is appended to a SHA-256 chain so retroactive tampering with audit logs breaks verification.
- Frontend supply chain —
pnpm install --frozen-lockfilein CI; lockfile committed; Dependabot raises PRs on changes.
A09 — Security Logging & Alerting Failures
Can't detect or respond to attacks. Now includes 'alerting'.
Grit ships middleware.LogSecurityEvent(ctx, db, userID, event, ip, ua)with a typed enum of every event that matters for SOC 2 / ISO 27001 / GDPR: login success/failure, logout, password change, password-reset request, TOTP enable/disable, TOTP challenge failure, role change, account lock, AuthZ denial, suspicious request.
Events flow into the existing tamper-evident activity log (each row stores a SHA-256 hash of the previous row plus its canonical fields, so a retroactive deletion breaks the chain at verification time).
Alerting is wired through Sentinel's AuthShield + Anomaly modules and visible in the /sentinel/ui dashboard. Hook external alerting (PagerDuty, Slack, email) via Sentinel webhooks for spikes in failed logins, AuthZ denials, or WAF blocks.
A10 — Mishandling of Exceptional Conditions
NEW in 2025 — bad error handling, 'failing open', edge cases.
Grit's middleware fails closed by convention: authz.MustOwn returns 404 on any error (DB unavailable, not found, wrong owner) — the request never reaches the handler in an ambiguous state. gin.Recovery() turns panics into 500s rather than leaking stack traces. Production builds set gin.SetMode(gin.ReleaseMode).
The CSRF, rate-limit, and idempotency middleware all return explicit error responses on the failure path — there is no "continue without check" branch that an attacker can race into.
Pre-launch hardening checklist
The fastest audit pass — work the list, tick the box. Every item is a concrete Grit affordance.
| Check | Where |
|---|---|
| JWT_SECRET ≥ 32 chars, rotated per env | .env |
| APP_ENV=production set | .env |
| SENTINEL_ENABLED=true + WAF in block mode | .env |
| AUTO_SEED=false in production | .env |
| CORS_ORIGINS narrowed to actual frontends | .env |
| Every authenticated handler calls authz.MustOwn / RequireRoles | internal/handlers/ |
| Any user-supplied URL fetched via safefetch | internal/safefetch |
| govulncheck + pnpm audit green | .github/workflows/security.yml |
| k6 average-load passes p95 SLO | tests/k6/average-load.js |
| Sentinel dashboard credentials changed from default | .env |
The defences above don't replace testing. See the Testing page for the k6 + pentest methodology to prove they hold.
