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 IPs2 SecurityHeaders CSP, HSTS, X-Frame-Options, nosniff3 MaxBodySize 10 MB cap — before anything reads the body4 RequestID X-Request-ID, threaded through every log line5 Logger method, path, status, latency, request id6 Recovery turns a panic into a 500 instead of a dead process7 CORS preflight + Access-Control-* headers8 Gzip response compression9 AutoCSRF enforces ONLY on cookie-authenticated mutations10 Idempotency replays the cached 2xx when Idempotency-Key repeats11 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:
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 happens | Global middleware, high in the list |
| Require a permission for one route | middleware.RequireRole("perm:invoices.delete") on that route |
| Require it for a whole section | .Use() on a route group |
| Check the caller owns the record | In the handler, via authz.MustOwn — it needs the record |
| Business rules | The service layer. Handlers stay thin; see Services |
| Record that something happened | services.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:
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 chainreturn}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().
