Keeping Tokens Off the Browser:
The BFF Session Pattern
The gateway pattern demo puts a raw JWT in the browser's localStorage and says so plainly. This post covers the alternative: a small, purpose-built session broker that keeps the JWT server-side entirely, using this project's own code — not a third-party proxy binary marketed by name.
The problem with browsers holding JWTs
The standard SPA auth pattern looks like this: user logs in, the browser receives a JWT access token,
stores it in localStorage or memory, and sends it as Authorization: Bearer <token>
on every request. It works. But it has a property that's easy to overlook until it isn't:
the token contains your user's identity and permissions, and it's sitting in
a JavaScript runtime that is one XSS vulnerability away from exfiltration.
Once an attacker has your access token, they can impersonate you until it expires. The JWT expiry window becomes a window during which the breach is silent — there's no server-side switch to flip.
- XSS reads JWT from localStorage → attacker gains full user access
- JWT in browser memory → visible in DevTools, accessible to browser extensions
- Refresh token in browser → attacker can silently maintain access after password change
- No server-side revocation → can't invalidate a stolen token before expiry
The BFF pattern: move the tokens server-side
The Backend-for-Frontend (BFF) pattern solves this by keeping tokens on the server. The browser holds an opaque session identifier — a random value with no claims, no expiry, no user data. The server maps that identifier to the actual JWT and refresh token stored in Redis.
session-service X-Session-ID header GET /internal/resolve → session-service looks up the session_id in Redis user_id, user_tier — never the JWT itself Why this is our own code, not a proxy binary
There's a well-known off-the-shelf option for this — a reverse proxy that sits in front of your app, handles the OIDC dance, and stores sessions in Redis. It's a fine choice in production. We're deliberately not naming or featuring one here, for the same reason the rest of this site avoids naming specific vendor products as the point of a demo: the pattern is what matters, not the brand.
Building the ~150-line FastAPI session-service ourselves
also made the mechanics visible in a way a black-box proxy wouldn't: the opaque session ID generation,
the Redis key structure, the auto-refresh logic, the revocation path.
Building it surfaced the architecture questions early: What happens when the resolve call can't reach
Redis? (Fail-closed — no session found means no access.) What's the revocation model? (DELETE the Redis
key → instant, no JWT expiry window.) How does auto-refresh work transparently? (Check
exp on every resolve, call Keycloak's
refresh endpoint if within 30s.)
What session-service actually does
- • Brokers login via Keycloak ROPC (Direct Grant)
- • Stores JWT + refresh token in Redis (server-side only)
- • Hands the browser an opaque session_id — no claims, no expiry
- • Resolves session_id → user context on every API call
- • Auto-refreshes the access token before expiry (transparent)
- • Handles logout + Keycloak refresh token revocation
- • Expose any JWT or token to the browser
- • Require changes to backend services that read the resolved headers
- • Replace OPA — it's authentication brokering, not authorization
- • Replace APISIX — it's a session broker, not a gateway
- • Store passwords
- • Handle business-rule enforcement
Instant revocation: the biggest win
With pure JWT authentication — the gateway pattern demo, for example — revoking access requires waiting for the token to expire. A 2-minute access token means a 2-minute window after logout during which the user can still access protected resources, because logout there is purely client-side (clear localStorage; the JWT itself doesn't know it's been "logged out").
With the BFF pattern and Redis, revocation is instant:
DELETE /session-api/logout The same model works for admin-initiated revocation: delete the Redis key server-side and the user is locked out immediately. For a terminated employee or a compromised account, the revocation window is measured in milliseconds, not minutes.
Full architecture
Internet
│
▼
[Caddy :80/443]
│
├── /auth/* → [Keycloak :8080] → Postgres
│
├── /session-api/* → [session-service :8000] → [Redis :6379]
│ (JWT + refresh token, per-session, TTL'd)
│
├── /gateway-demo/* ┐
│ /demo-api/* ├── [APISIX :9080] ← real JWKS verification
│ /todo-api/* │ │
└── /sidecar-demo/* ┘ ├── [OPA :8181] ← gateway pattern
├── [Demo API :3000] ← [OPA sidecar] ← sidecar pattern
└── [Todo API :3001] ← [OPA sidecar] ← sidecar pattern
└── /* → [Demo UI] ← Astro static site
Tradeoffs and when to use this pattern
- • Your SPA needs to call protected APIs
- • You need instant session revocation
- • XSS is a realistic threat surface
- • You have compliance requirements around token storage
- • Multiple backend services share the same user context
- • Server-to-server API calls (use client credentials, no browser involved)
- • Mobile apps (use PKCE with the platform secure store, no proxy needed)
- • Simple single-service apps without sensitive data
- • You can't add a session broker in front of your existing API
- • The added hop latency matters at your scale — see the gateway pattern demo instead
Enterprise IDP federation
In the demo, Keycloak is both the IdP and the SP. In a real enterprise deployment, your company's
existing IDP becomes the upstream. Keycloak federates to it via OIDC or SAML, enriches the token with
additional claims (user_tier,
department, region), and issues the JWT that session-service stores.
Nothing downstream changes. session-service doesn't care which IdP issued the JWT — it only talks to Keycloak. OPA evaluates the claims. Your applications get the same resolved user context regardless of whether the user authenticated with a local Keycloak account, their corporate SSO, or a federated external IdP.
Compare it live
See the tradeoff directly: the gateway pattern demo keeps the JWT in the browser; the sidecar demo moves authorization off the gateway entirely. This post's session-service pattern is the third option — keep the token off the browser altogether.