JWT RS256 & Tier Middleware
skyflow intermediate 5 min read
ELI5
Skyflow signs JWTs with an RS256 private key kept only by Core API; every other service verifies with the public key. Every authenticated request goes through three Fiber middlewares in order: JWT verify → tier check → rate limit. Logout deletes the Redis session, instantly invalidating subsequent requests even if the token’s exp is still in the future.
Technical Deep Dive
Key Material
| File | Holder | Purpose |
|---|---|---|
/secrets/jwt-private.pem | core-api | sign access + refresh tokens |
/secrets/jwt-public.pem | core-api, realtime-gateway | verify signatures |
Asymmetric (RS256) so the verifier never holds the signing key — minimises blast radius if a downstream service is compromised.
Token Lifecycle
sequenceDiagram autonumber participant C as Client participant API as core-api participant RD as Redis participant RT as realtime-gateway
C->>API: POST /api/v1/auth/login API->>API: bcrypt compare API->>API: sign JWT (RS256, 1h exp) API->>RD: SET session:{user_id} TTL=1h API-->>C: {access_token, refresh_token, expires_at} C->>API: Authorization: Bearer <jwt> API->>API: JWTAuth middleware (verify) API->>RD: EXISTS session:{user_id} API->>API: RequireTier / RateLimit API-->>C: 200 C->>API: POST /api/v1/auth/logout API->>RD: DEL session:{user_id} API-->>C: 204 C->>RT: GET /sse?token=<jwt> RT->>RT: verify with jwt-public.pem RT-->>C: SSE streamMiddleware Chain
flowchart LR R[Request] --> M1["JWTAuth — verify RS256, set user_id"] M1 -->|"missing/invalid"| E1[401 unauthorized] M1 --> M2["RequireTier — fetch users.tier vs minTier"] M2 -->|"tier < min"| E2[403 upgrade_required] M2 --> M3["RateLimit — INCR ratelimit:{u}:{path}"] M3 -->|"count > limit"| E3[429 rate_limit_exceeded] M3 --> H[Handler]Status Codes
| Code | Trigger | Body field |
|---|---|---|
| 401 | missing / bad / expired token, or revoked session | – |
| 403 | tier below minTier | error: "upgrade_required", required_tier: … |
| 429 | per-user-per-path counter exceeded | error: "rate_limit_exceeded", limit, reset_at |
Defence-in-Depth
| Surface | Mechanism |
|---|---|
| Password storage | bcrypt cost 12 |
| API key storage | SHA-256 hash, plaintext shown once on creation |
| Session revocation | Redis DEL session:{user_id} on logout |
| Webhook authenticity | HMAC verification (Polar) |
| Transport | TLS terminated at Caddy |
| Headers | helmet middleware (CSP, HSTS) |
| SQL injection | pgx parameterised queries |
Refresh Flow
POST /api/v1/auth/refresh exchanges a refresh token for a new short-lived access token. The refresh token lifetime is longer than 1h (exact value not in spec); access token TTL is 1h to bound damage from leak.
Key Terms
- RS256 → RSA-SHA256 asymmetric signature; only the holder of the private key can sign, anyone with the public key can verify
- Session in Redis → enables instant revocation that JWT alone cannot provide (since JWTs are stateless)
- Bearer token →
Authorization: Bearer <jwt>header form; on SSE, falls back to?token=query param RequireTier(minTier)→ Fiber middleware factory;Tierenum comparison is integer ordering (DRIFT<LIFT<JET<ORBIT)
Q&A
Q: Why does the realtime-gateway only get the public key? A: It only needs to verify, not issue. Withholding the private key means a compromised gateway cannot forge tokens.
Q: Why a Redis session on top of a JWT?
A: To support immediate logout / revocation. A pure JWT is valid until exp; the Redis check converts the JWT into a session-bound token so logout actually does something.
Q: How is the SSE endpoint authenticated when the browser cannot set custom headers on EventSource?
A: The token is passed as ?token= query param. This is acceptable because the connection is TLS-encrypted and the URL is not logged at the request-line level by Caddy in the dev config — but it does land in browser history. Treat as documented trade-off.
Q: What happens on token expiry while an SSE connection is open? A: The current spec does not document mid-connection re-validation; the connection persists until the client disconnects or the gateway restarts. Refresh-while-streaming is a known gap, not a guarantee.
Examples
Like a museum wristband. The cashier (core-api) issues a holographic band signed with their stamp (RS256 private key). Every gallery guard (other services / middleware) holds a UV light (public key) to verify, then checks your tier sticker (RequireTier) before letting you into the planetarium. Cutting the wristband at the exit (logout / Redis DEL) invalidates it even though the date printed on it is still good.
neighbors on the map
- Multi-Strategy Authentication debugging 401 errors across different clients
- Airlock JWT Handoff & Session Cookies debugging login loops or session expiry
- Chronicle JWT Claims & Dev Tokens issuing tokens from an external auth provider
- LORE RBAC & Airlock Auth Flow implementing authentication in a new LORE page
- Airlock Bearer Auth diagnosing a 401 from /v1/ingest/*