Backend

The Request Lifecycle

Everything a request passes through, in the order it actually happens. Most of the confusing bugs in a Go API — a CORS header that never appears, a WAF rule that blocks the wrong thing, a CSRF rejection on a mobile client — are ordering problems. This page is the map.

The order

From internal/routes/routes.go, top to bottom. Each layer wraps the ones below it, so a request goes down the list and the response comes back up.

┌─ request in
1 Maintenance 503 for everyone but allowlisted IPs
2 SecurityHeaders CSP, HSTS, X-Frame-Options, nosniff
3 MaxBodySize 10 MB cap — before anything reads the body
4 RequestID X-Request-ID, threaded through every log line
5 Logger method, path, status, latency, request id
6 Recovery turns a panic into a 500 instead of a dead process
7 CORS preflight + Access-Control-* headers
8 Gzip response compression
9 AutoCSRF enforces ONLY on cookie-authenticated mutations
10 Idempotency replays the cached 2xx when Idempotency-Key repeats
11 Sentinel WAF, rate limits, AuthShield, anomaly + geo (if enabled)
12 Pulse tracing, N+1 detection, runtime metrics (if enabled)
├─ route group middleware
│ protected: Auth → ActivityLogger
│ admin: Auth → RequireRole("ADMIN")
└─ your handler

The global layers

Order here is deliberate, and a few positions are load-bearing:

  • Maintenance is first so a maintenance window costs nothing — no body read, no DB, no auth.
  • MaxBodySize precedes anything that reads the body. A cap applied after parsing is not a cap.
  • Recovery sits after Logger, not before. Gin runs middleware in registration order, so Logger wraps Recovery and a panicking request still gets logged with its status. Flip them and panics vanish from your logs.
  • CORS runs before auth, because a browser preflight (OPTIONS) carries no credentials. Put CORS behind auth and every cross-origin call fails preflight with a 401 that never reaches your handler — the single most common “my frontend can't call my API” cause.

CSRF only applies to cookie sessions

AutoCSRF enforces the double-submit token only when a request is a state-changing method and authenticated by cookie. It deliberately skips:

  • safe methods (it issues the token there instead);
  • requests carrying Authorization: Bearer — those authenticate explicitly and can't be forged cross-site;
  • bootstrap endpoints (login, register, refresh, password reset, TOTP verify) which have no token yet;
  • the SAML assertion consumer — the identity provider posts it from its own origin and will never have a token.

This is why a mobile or desktop client never sends a CSRF header and still works: those flows are bearer-authenticated. If you add an endpoint that must be callable cross-origin without a session, it needs an explicit exemption — see internal/middleware/csrf.go.

Group middleware

Below the global stack, routes hang off groups that add their own layers:

internal/routes/routes.go
v1 := r.Group("/api/" + APIVersion)
// Public: auth endpoints, SSO, public form submissions.
auth := v1.Group("/auth")
// Authenticated: everything a signed-in user can reach.
protected := v1.Group("")
protected.Use(middleware.Auth(db, authService))
protected.Use(middleware.ActivityLogger(db))
// Admin-only.
admin := v1.Group("")
admin.Use(middleware.Auth(db, authService))
admin.Use(middleware.RequireRole("ADMIN"))

Auth populates the gin context with user_id, user_email, user_role and user_grants, so everything after it — including your handler — can read the caller without another query.

Where does my logic go?

You want to…Put it…
Reject a request before any work happensGlobal middleware, high in the list
Require a permission for one routemiddleware.RequireRole("perm:invoices.delete") on that route
Require it for a whole section.Use() on a route group
Check the caller owns the recordIn the handler, via authz.MustOwn — it needs the record
Business rulesThe service layer. Handlers stay thin; see Services
Record that something happenedservices.LogActivity from the handler, after it succeeds

Adding your own middleware

A Grit middleware is an ordinary Gin one. Register it in routes.go at the position its job implies — cheap rejections high, anything needing an authenticated user below Auth:

internal/middleware/tenant.go
func RequireTenant() gin.HandlerFunc {
return func(c *gin.Context) {
tenant := c.GetHeader("X-Tenant")
if tenant == "" {
respond.BadRequest(c, "X-Tenant header is required")
c.Abort() // Abort, not return — return alone continues the chain
return
}
c.Set("tenant", tenant)
c.Next()
}
}

c.Abort() is not optional. Writing a response and returning does not stop the chain — the handler still runs, and you get a rejected request that also did the work. Any middleware that denies a request must call c.Abort().