JWT Auth & Per-IP Rate Limiting
nestr intermediate 5 min read
ELI5
Every API call walks past two gates: a bouncer who checks your wristband signature against the venue’s wristband-printer keys (JWKS), and a turnstile that only spins ten times a second per address. Pass both and you reach the room.
Technical Deep Dive
Auth Pipeline
sequenceDiagram participant C as Client participant L as loggingMW participant R as rateLimit participant J as JWTValidator participant H as Handler C->>L: Authorization: Bearer <jwt> L->>R: + X-Request-ID R->>R: limiter[ip].Allow()? alt over budget R-->>C: 429 Too Many Requests else ok R->>J: extractBearerToken J->>J: keyfunc(JWKS_URL).VerifyJWT alt invalid J-->>C: 401 else valid J->>H: ctx with Claims{Sub,Email} H-->>C: 200 + body end endJWT Validator
engine/internal/auth/jwt.go builds a keyfunc.Keyfunc from JWKS_URL (env). Tokens are parsed against Claims{Sub, Email, jwt.RegisteredClaims}. Helpers UserIDFromContext(ctx) and ClaimsFromContext(ctx) read what the middleware injected under UserIDKey / ClaimsKey.
If JWKS_URL is empty, the auth middleware is not installed at all — the API is fully open. This is intentional for local dev but must not ship.
WebSocket Auth
/ws cannot send custom headers from a browser, so WebSocketAuthCheck accepts the token via the ?token= query parameter as well as the Authorization header. Same validator, different extraction path.
Rate Limiter
A RateLimiter map of *rate.Limiter keyed by client IP. Defaults: 10 RPS, burst 20. Limiters are created lazily on first request from an IP; there is no eviction of stale IPs in this revision (memory grows monotonically with unique source IPs — relevant for long-running deployments behind a NAT pool).
CORS
CORS_ALLOWED_ORIGINS (CSV) env decides which Origin values are echoed back. Default list: http://localhost:3000, http://localhost:5173, https://app.nestr.tools. A * entry allows everything. Preflight OPTIONS returns 204.
Key Terms
- JWKS → JSON Web Key Set; the IdP publishes the public keys used to verify token signatures.
- Token bucket →
golang.org/x/time/rate.Limiter; refills atrateper second up toburst. - Claims → the parsed JWT payload made available to handlers via context keys.
Q&A
Q: What stops a noisy IP from starving the limiter map? A: Nothing in this revision. Limiters are never garbage-collected; this is a known scaling seam for high-cardinality source IP environments.
Q: Does an expired token return 401 or 403? A: 401 — the validator surfaces signature/exp failures uniformly through the same error path.
Q: How is the WebSocket connection authorised once the upgrade succeeds?
A: It isn’t, beyond the initial handshake. The upgrade carries the JWT via header or ?token=; once the socket is open, frames are not re-validated.
Examples
A misconfigured client sending 50 RPS from one IP will see roughly 30 successful responses in the first 3 s (burst of 20 + ~10/s sustained) followed by a steady stream of 429s until traffic drops below 10 RPS.
neighbors on the map
- Multi-Strategy Authentication debugging 401 errors across different clients
- Airlock JWT Handoff & Session Cookies debugging login loops or session expiry
- Tier-Based Rate Limiting debugging 429 errors for specific users
- Airlock Bearer Auth diagnosing a 401 from /v1/ingest/*
- In-Process Rate-Limit Bucket investigating ingest 429s
- Chronicle JWT Claims & Dev Tokens issuing tokens from an external auth provider