CRUMB a card from devarno-cloud

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
end

JWT 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 bucketgolang.org/x/time/rate.Limiter; refills at rate per second up to burst.
  • 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