The only secure way to authenticate a browser-based SPA in a microservice architecture is to keep raw OAuth tokens off the browser entirely. This article implements the Token-Mediating BFF security pattern — httpOnly session cookies, confidential OAuth client token exchange, silent refresh, and layered CSRF defenses.
Architecture is not about drawing boxes on a whiteboard — it is about enforcing boundary contracts, deterministic caching, and secure data mediation across independent release units. Every architectural decision in this series has been about boundary clarity: which package can import which, which team owns which service, which BFF serves which client. The security boundary is the most consequential of all, because its failure mode is not a broken build or a degraded user experience — it is account compromise at scale.
The most common security mistake in browser-based SPA architectures is storing OAuth access tokens in the browser. Developers know that localStorage is XSS-accessible. They use memory storage instead, feeling safer. Memory storage is ephemeral and survives no page refresh, so they build silent refresh timers. The timer is a JavaScript construct, and JavaScript in the browser is one XSS vulnerability away from full control. The token is still at risk.
The Token-Mediating BFF pattern eliminates this attack surface entirely. No access token ever reaches the browser. The BFF is the OAuth client, the BFF holds the tokens, and the browser holds only an encrypted session cookie that is unreadable by JavaScript. This article implements the pattern end to end.
// ❌ Pattern 1: localStorage — persisted, globally XSS-accessiblelocalStorage.setItem('access_token', accessToken)
// Any third-party script, browser extension, or XSS payload can:const stolen = localStorage.getItem('access_token')
// Then exfiltrate to attacker's server. Game over.// ❌ Pattern 2: sessionStorage — same-tab, still XSS-accessiblesessionStorage.setItem('access_token', accessToken)
// Same attack surface as localStorage within the same tab.// ❌ Pattern 3: JavaScript memory variable — safest, but still at riskletaccessToken: string | null = null// Silent refresh logic runs on a setInterval — if XSS executes in the same// window context, it can intercept the token from memory or intercept the// refresh response. Also: ephemeral — destroyed on page reload.
All three patterns share the fundamental vulnerability: the access token is a string value accessible within the JavaScript execution context. If an attacker can execute JavaScript in your application (XSS, malicious third-party script, prototype pollution), they can extract the token.
This runs silently. No user interaction required. No browser warning. The attacker now has a valid access token with the user's full permissions, valid until it expires. If the refresh token was also stored (a common pattern), the attacker can maintain persistent access.
Performance / Safety Warning
Third-party JavaScript in your application bundle — analytics, A/B testing, chat widgets, tag managers — runs with the same origin privileges as your first-party code. A compromised third-party script is functionally equivalent to XSS. Any token in localStorage or sessionStorage is accessible to every script on the page, regardless of origin.
The Token-Mediating BFF pattern is defined by the IETF BFF Security Profile and is the current OWASP recommendation for browser-based SPA authentication in microservice architectures.
A confidential client is an OAuth 2.0 client that can securely hold a CLIENT_SECRET. Server-side applications (like a BFF) are confidential clients. Browser-based SPAs cannot be confidential clients — the CLIENT_SECRET would be exposed in the bundle.
# Auth Server Configuration (e.g., Keycloak, Auth0, AWS Cognito)
Client ID: bff-web
Client Secret: <secret — stored only in BFF environment, never in browser>
Grant Types: authorization_code, refresh_token
Redirect URIs: https://bff.example.com/auth/callback
PKCE: required (S256 code challenge)
Token Endpoint Auth Method: client_secret_post
// src/config/session.tsimporttype { IronSessionOptions } from'iron-session'exportconstSESSION_OPTIONS: IronSessionOptions = {
cookieName: '__session',
password: process.env.SESSION_SECRET!, // Must be 32+ chars; rotate with key versioningcookieOptions: {
secure: process.env.NODE_ENV === 'production', // HTTPS only in productionhttpOnly: true, // JavaScript cannot read this cookie — eversameSite: 'lax', // CSRF protection for top-level navigationsmaxAge: 60 * 60 * 24, // 24-hour sessionpath: '/'
}
}
// Session data shape (stored encrypted inside the cookie)exportinterfaceSessionData {
sessionId: string// Key into Redis — not the token itselfuserId: string// For logging and user-scoped operations
}
Crucial Requirement
The cookie stores only the sessionId — a random identifier that maps to the real tokens in Redis. The access token never travels in the cookie. This is a critical design decision: if the session cookie were somehow decrypted (e.g., if SESSION_SECRET leaked), the attacker would only see a session ID. The actual tokens are in Redis, protected by the server's network boundary.
// src/plugins/downstream-clients.tsexportdefaultfp(asyncfunctiondownstreamClients(app) {
app.decorate('services', {
product: {
async get<T>(path: string, accessToken: string, traceparent: string): Promise<T> {
const res = awaitfetch(`${process.env.PRODUCT_SERVICE_URL}${path}`, {
headers: {
// ← The token is attached HERE — never sent to the browser'Authorization': `Bearer ${accessToken}`,
'traceparent': traceparent,
'Content-Type': 'application/json'
},
signal: AbortSignal.timeout(3000)
})
if (!res.ok) thrownewError(`${res.status}${path}`)
return res.json() asPromise<T>
}
}
})
})
The access token flows: Redis → BFF middleware → Authorization header on downstream requests. It never touches the browser at any point in this chain.
Expand
Two-column before/after diagram. Left (broken): Browser holds access_token in localStorage, sends it directly to microservices, vulnerable to XSS extraction.…
httpOnly cookies solve XSS token theft. They create a new risk: CSRF (Cross-Site Request Forgery). Because the session cookie is automatically sent with every request to the BFF's origin, a malicious page on evil.example.com can trigger requests to bff.example.com and the browser will attach the session cookie.
HTML
<!-- evil.example.com — attacker's page --><formaction="https://bff.example.com/api/account/delete"method="POST"><buttontype="submit">Click here to win a prize!</button></form><!-- Browser submits the form and includes __session cookie automatically -->
SameSite=Lax (set in the session cookie options above) prevents the cookie from being sent with cross-origin POST, PUT, DELETE requests. Top-level GET navigations still include the cookie (this is correct — loading your app from a link should work).
This mitigates most CSRF vectors for form submissions and AJAX mutations.
For AJAX requests from your SPA, require a custom header that simple HTML form submissions and cross-origin fetch with no-cors mode cannot include:
TYPESCRIPT
// Client-side — add to all API requestsfetch('/api/product/123', {
headers: {
'X-Requested-With': 'XMLHttpRequest'// Custom header — CSRF indicator
}
})
TYPESCRIPT
// Server-side — verify the custom header on all mutation endpoints
app.addHook('preHandler', async (request, reply) => {
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method)) {
const xrw = request.headers['x-requested-with']
if (xrw !== 'XMLHttpRequest') {
return reply.status(403).send({ error: 'Missing X-Requested-With header' })
}
}
})
Pro Tip & Optimization
The triple defense (SameSite=Lax + Origin verification + X-Requested-With) provides defense-in-depth. No single layer is perfect: SameSite=Lax has browser compatibility edge cases, Origin headers are occasionally absent in proxy setups, and custom headers can be set by browser extensions. Together, they cover each other's gaps.
// src/plugins/cors.tsimport cors from'@fastify/cors'import fp from'fastify-plugin'exportdefaultfp(asyncfunctioncorsConfig(app) {
await app.register(cors, {
origin: (origin, callback) => {
if (!origin) {
// Server-to-server requests (no Origin header) — allowcallback(null, true)
return
}
const allowed = [
'https://app.example.com',
'https://staging.example.com',
...(process.env.NODE_ENV === 'development' ? ['http://localhost:3000'] : [])
]
if (allowed.includes(origin)) {
callback(null, true)
} else {
callback(newError(`CORS: origin ${origin} not allowed`), false)
}
},
credentials: true, // Required for cross-origin requests with cookiesmethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'X-Requested-With'],
// Do NOT include Authorization — the BFF handles token injection server-sidemaxAge: 3600
})
})
Performance / Safety Warning
Never set Access-Control-Allow-Origin: * on a BFF that serves credentials: true (cookies). This combination is forbidden by the CORS specification and is rejected by browsers. Always use an explicit allowlist.
// src/routes/auth.ts — logout handler
app.post('/auth/logout', async (request, reply) => {
const session = await getIronSession<SessionData>(
request.raw,
reply.raw,
SESSION_OPTIONS
)
if (session.sessionId) {
// Delete tokens from Redis — immediate revocationawait app.redis.del(`session:${session.sessionId}`)
}
// Destroy the session cookie
session.destroy()
// Optional: Notify auth server to revoke refresh token// (required for true global logout)if (session.userId) {
awaitfetch(`${process.env.AUTH_SERVER_URL}/logout`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: newURLSearchParams({
client_id: process.env.CLIENT_ID!,
client_secret: process.env.CLIENT_SECRET!
})
}).catch(err => request.log.warn({ err }, 'Failed to notify auth server of logout'))
}
return reply.redirect(302, '/?loggedOut=true')
})
Session destruction is immediate: the Redis key is deleted, making the session ID in the cookie worthless. Even if an attacker had captured the encrypted cookie value, the session ID it contains resolves to nothing in Redis.
Expand
Left-to-right flow trace of the Token-Mediating BFF Security Flow. Five nodes: Browser (sends encrypted httpOnly cookie __session=<enc>, labeled red for thre…
This concludes the Frontend Platform & Scale Architecture series. The six parts build a complete stack: from workspace symlinks (Part 1) through task orchestration (Part 2), token architecture (Part 3), distribution governance (Part 4), BFF aggregation (Part 5), and BFF security (Part 6). Every architectural decision reinforces the same principle: boundary contracts enforced by tooling, not team convention.
Research & Synthesis Note
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.
#BFF#OAuth 2.0#Security#Token Mediation#CSRF#Authentication#httpOnly Cookies
Written by Siddhant Deval
Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.