Backend-for-Frontend (BFF) Pattern with Keycloak
Last updated: March 2026
Single-page applications face a fundamental security problem: they run entirely in the browser, an environment where secrets cannot be kept. Storing OAuth tokens in localStorage or sessionStorage exposes them to cross-site scripting (XSS) attacks. The Backend-for-Frontend (BFF) pattern solves this by introducing a thin server-side layer that handles authentication on behalf of the frontend, keeping tokens where they belong — on the server.
This guide walks through a complete BFF implementation with Keycloak, using Node.js/Express as the backend proxy and a React/Next.js frontend as the consumer.
Why the BFF Pattern Matters
Traditional SPA authentication flows use the Authorization Code flow with PKCE, where the browser directly receives and stores tokens. While PKCE prevents authorization code interception, it does nothing to protect tokens once they arrive in the browser.
The BFF pattern addresses several security concerns:
- Token exposure: Access and refresh tokens never reach the browser. They live in server-side sessions, inaccessible to JavaScript.
- Refresh token security: Refresh tokens are long-lived credentials. Storing them in the browser is a significant risk. The BFF keeps them server-side.
- Simplified frontend code: The SPA does not need to manage token lifecycles, refresh logic, or error handling for expired tokens.
- CSRF protection: The BFF uses HTTP-only cookies with SameSite attributes, combined with CSRF tokens, to prevent cross-site request forgery.
For a deeper look at how tokens work and how to inspect them, try the JWT Token Analyzer.
Architecture Overview
The BFF pattern introduces three components:
- Frontend (SPA): A React or Next.js application that communicates only with the BFF. It never sees OAuth tokens directly.
- BFF (Node.js/Express): A server-side application that handles the OAuth flow with Keycloak, stores tokens in a session, and proxies API requests with the access token attached.
- Resource API: A downstream API that validates access tokens issued by Keycloak.
The flow works as follows:
- The user clicks “Login” in the SPA.
- The SPA redirects to the BFF’s
/auth/loginendpoint. - The BFF redirects the user to Keycloak’s authorization endpoint.
- After authentication, Keycloak redirects back to the BFF’s callback URL with an authorization code.
- The BFF exchanges the code for tokens and stores them in a server-side session.
- The BFF sets an HTTP-only session cookie on the user’s browser.
- Subsequent API calls from the SPA go through the BFF, which attaches the access token before proxying them to the resource API.
Keycloak Client Configuration
Create a confidential client in Keycloak for the BFF:
- Navigate to your realm in the Keycloak Admin Console.
- Go to Clients and click Create client.
- Set the following values:
- Client ID:
bff-client - Client Protocol:
openid-connect
- Client ID:
- On the next screen:
- Client Authentication: ON (this makes it a confidential client)
- Standard Flow Enabled: ON
- Direct Access Grants Enabled: OFF
- Set the redirect URIs:
- Valid Redirect URIs:
http://localhost:3001/auth/callback - Post Logout Redirect URIs:
http://localhost:5173 - Web Origins:
http://localhost:5173
- Valid Redirect URIs:
Copy the client secret from the Credentials tab. You will need it for the BFF configuration.
For more on configuring Single Sign-On across your applications, see the Skycloak SSO feature page.
BFF Implementation with Express.js
Install the required dependencies:
mkdir keycloak-bff && cd keycloak-bff
npm init -y
npm install express express-session axios cors cookie-parser csrf-csrf dotenv
Create the main BFF server:
// server.js
import express from 'express';
import session from 'express-session';
import axios from 'axios';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import { doubleCsrf } from 'csrf-csrf';
import crypto from 'crypto';
import 'dotenv/config';
const app = express();
const {
KEYCLOAK_URL = 'http://localhost:8080',
KEYCLOAK_REALM = 'my-realm',
KEYCLOAK_CLIENT_ID = 'bff-client',
KEYCLOAK_CLIENT_SECRET,
SESSION_SECRET = crypto.randomBytes(32).toString('hex'),
FRONTEND_URL = 'http://localhost:5173',
BFF_PORT = 3001,
API_BASE_URL = 'http://localhost:4000',
} = process.env;
const KC_BASE = `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}`;
const KC_TOKEN_URL = `${KC_BASE}/protocol/openid-connect/token`;
const KC_AUTH_URL = `${KC_BASE}/protocol/openid-connect/auth`;
const KC_LOGOUT_URL = `${KC_BASE}/protocol/openid-connect/logout`;
const KC_USERINFO_URL = `${KC_BASE}/protocol/openid-connect/userinfo`;
// Session configuration
app.use(cookieParser());
app.use(
session({
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 30 * 60 * 1000, // 30 minutes
},
})
);
// CORS: allow the frontend origin with credentials
app.use(
cors({
origin: FRONTEND_URL,
credentials: true,
})
);
// CSRF protection
const {
generateToken,
doubleCsrfProtection,
} = doubleCsrf({
getSecret: () => SESSION_SECRET,
cookieName: '__csrf',
cookieOptions: {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
},
getTokenFromRequest: (req) => req.headers['x-csrf-token'],
});
// Apply CSRF protection to state-changing routes
app.use('/api', doubleCsrfProtection);
// ──────────────────────────────────────────────
// Auth routes
// ──────────────────────────────────────────────
// Start the login flow
app.get('/auth/login', (req, res) => {
const state = crypto.randomBytes(16).toString('hex');
req.session.oauthState = state;
const params = new URLSearchParams({
client_id: KEYCLOAK_CLIENT_ID,
redirect_uri: `http://localhost:${BFF_PORT}/auth/callback`,
response_type: 'code',
scope: 'openid profile email',
state,
});
res.redirect(`${KC_AUTH_URL}?${params}`);
});
// Handle the callback from Keycloak
app.get('/auth/callback', async (req, res) => {
const { code, state } = req.query;
if (!code || state !== req.session.oauthState) {
return res.status(400).json({ error: 'Invalid callback parameters' });
}
delete req.session.oauthState;
try {
const tokenResponse = await axios.post(
KC_TOKEN_URL,
new URLSearchParams({
grant_type: 'authorization_code',
client_id: KEYCLOAK_CLIENT_ID,
client_secret: KEYCLOAK_CLIENT_SECRET,
code,
redirect_uri: `http://localhost:${BFF_PORT}/auth/callback`,
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
const { access_token, refresh_token, id_token, expires_in } = tokenResponse.data;
// Store tokens in the server-side session
req.session.tokens = {
accessToken: access_token,
refreshToken: refresh_token,
idToken: id_token,
expiresAt: Date.now() + expires_in * 1000,
};
res.redirect(FRONTEND_URL);
} catch (err) {
console.error('Token exchange failed:', err.response?.data || err.message);
res.status(500).json({ error: 'Authentication failed' });
}
});
// Check authentication status (frontend polls this)
app.get('/auth/me', async (req, res) => {
if (!req.session.tokens) {
return res.json({ authenticated: false });
}
// Refresh the token if it is about to expire
await refreshTokenIfNeeded(req);
try {
const userinfo = await axios.get(KC_USERINFO_URL, {
headers: { Authorization: `Bearer ${req.session.tokens.accessToken}` },
});
res.json({
authenticated: true,
user: userinfo.data,
csrfToken: generateToken(req, res),
});
} catch {
return res.json({ authenticated: false });
}
});
// Logout
app.post('/auth/logout', (req, res) => {
const idToken = req.session.tokens?.idToken;
req.session.destroy(() => {
res.clearCookie('connect.sid');
const params = new URLSearchParams({
id_token_hint: idToken || '',
post_logout_redirect_uri: FRONTEND_URL,
});
res.json({ logoutUrl: `${KC_LOGOUT_URL}?${params}` });
});
});
// ──────────────────────────────────────────────
// Token refresh logic
// ──────────────────────────────────────────────
async function refreshTokenIfNeeded(req) {
const tokens = req.session.tokens;
if (!tokens) return;
// Refresh if the token expires in less than 60 seconds
if (tokens.expiresAt - Date.now() > 60_000) return;
try {
const response = await axios.post(
KC_TOKEN_URL,
new URLSearchParams({
grant_type: 'refresh_token',
client_id: KEYCLOAK_CLIENT_ID,
client_secret: KEYCLOAK_CLIENT_SECRET,
refresh_token: tokens.refreshToken,
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
req.session.tokens = {
accessToken: response.data.access_token,
refreshToken: response.data.refresh_token,
idToken: response.data.id_token,
expiresAt: Date.now() + response.data.expires_in * 1000,
};
} catch (err) {
console.error('Token refresh failed:', err.response?.data || err.message);
delete req.session.tokens;
}
}
// ──────────────────────────────────────────────
// API proxy
// ──────────────────────────────────────────────
app.use('/api/:path(*)', async (req, res) => {
if (!req.session.tokens) {
return res.status(401).json({ error: 'Not authenticated' });
}
await refreshTokenIfNeeded(req);
try {
const response = await axios({
method: req.method,
url: `${API_BASE_URL}/${req.params.path}`,
headers: {
Authorization: `Bearer ${req.session.tokens.accessToken}`,
'Content-Type': req.headers['content-type'] || 'application/json',
},
data: req.method !== 'GET' ? req.body : undefined,
params: req.method === 'GET' ? req.query : undefined,
});
res.status(response.status).json(response.data);
} catch (err) {
const status = err.response?.status || 502;
res.status(status).json(err.response?.data || { error: 'Proxy error' });
}
});
app.listen(BFF_PORT, () => {
console.log(`BFF running on http://localhost:${BFF_PORT}`);
});
This implementation handles the entire OAuth lifecycle: login initiation, authorization code exchange, token storage, automatic token refresh, and API proxying.
Frontend Integration with React
The frontend does not manage tokens at all. It communicates with the BFF via session cookies.
// src/api/auth.js
const BFF_URL = 'http://localhost:3001';
export async function checkAuth() {
const res = await fetch(`${BFF_URL}/auth/me`, { credentials: 'include' });
return res.json();
}
export function login() {
window.location.href = `${BFF_URL}/auth/login`;
}
export async function logout() {
const res = await fetch(`${BFF_URL}/auth/logout`, {
method: 'POST',
credentials: 'include',
});
const { logoutUrl } = await res.json();
window.location.href = logoutUrl;
}
export async function apiFetch(path, options = {}) {
const authData = await checkAuth();
const res = await fetch(`${BFF_URL}/api/${path}`, {
...options,
credentials: 'include',
headers: {
...options.headers,
'Content-Type': 'application/json',
'X-CSRF-Token': authData.csrfToken,
},
});
if (res.status === 401) {
login();
return;
}
return res.json();
}
A simple React component that uses the BFF:
// src/App.jsx
import { useEffect, useState } from 'react';
import { checkAuth, login, logout, apiFetch } from './api/auth';
function App() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
checkAuth().then((data) => {
if (data.authenticated) {
setUser(data.user);
}
setLoading(false);
});
}, []);
if (loading) return <div>Loading...</div>;
if (!user) {
return (
<div>
<h1>Welcome</h1>
<button onClick={login}>Sign In</button>
</div>
);
}
return (
<div>
<h1>Hello, {user.name}</h1>
<p>Email: {user.email}</p>
<button onClick={logout}>Sign Out</button>
</div>
);
}
export default App;
CSRF Protection in the BFF
The BFF uses the double-submit cookie pattern for CSRF protection. Here is how it works:
- When the frontend calls
/auth/me, the BFF generates a CSRF token and returns it in the JSON response. - The BFF also sets an HTTP-only cookie (
__csrf) containing a hash of the token. - On every state-changing request (
POST,PUT,DELETE), the frontend sends the CSRF token in theX-CSRF-Tokenheader. - The
csrf-csrfmiddleware verifies that the header value matches the cookie value.
This approach works well with SPAs because the token is tied to the session and the cookie, making it impossible for a third-party site to forge requests.
Session Management Considerations
The BFF’s session store directly impacts scalability and reliability. The in-memory session store used above works for development but is not suitable for production. Use a persistent store such as Redis:
npm install connect-redis ioredis
import RedisStore from 'connect-redis';
import { Redis } from 'ioredis';
const redisClient = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: 6379,
});
app.use(
session({
store: new RedisStore({ client: redisClient }),
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 30 * 60 * 1000,
},
})
);
For Keycloak session timeout configuration, align the BFF session duration with your Keycloak realm’s SSO session settings. See Session Management for more on managing user sessions in a managed Keycloak environment, and the companion post on Keycloak session timeout configuration for detailed timeout values.
Security Hardening Checklist
When deploying a BFF in production, address these items:
| Concern | Recommendation |
|---|---|
| Cookie security | Set secure: true, httpOnly: true, sameSite: 'lax' |
| CORS | Restrict origin to your exact frontend domain |
| CSRF | Use double-submit cookie pattern on all mutating endpoints |
| Session store | Use Redis or another persistent store |
| HTTPS | Terminate TLS at the load balancer or use HTTPS throughout |
| Token refresh | Refresh proactively before expiry (60-second buffer) |
| Rate limiting | Add rate limiting to /auth/login to prevent abuse |
| Helmet | Use the helmet middleware for security headers |
For a broader view of security best practices, see Skycloak’s Security page.
BFF vs. Direct SPA Authentication
| Aspect | BFF Pattern | Direct SPA (PKCE) |
|---|---|---|
| Token storage | Server-side session | Browser storage |
| XSS token theft risk | None (tokens not in browser) | High |
| Refresh token handling | Server-side, automatic | Browser-side, complex |
| CSRF risk | Mitigated with double-submit cookie | Not applicable |
| Infrastructure cost | Requires BFF server | No additional server |
| Complexity | More moving parts | Simpler initial setup |
The BFF pattern is the recommended approach for any application handling sensitive data. OAuth 2.0 Security Best Current Practice (RFC 9700) explicitly recommends keeping tokens out of the browser.
Deploying the BFF
For local development, use our Keycloak Docker Compose Generator to spin up a Keycloak instance quickly.
In production, deploy the BFF as a standard Node.js application behind a reverse proxy. The key deployment considerations are:
- Sticky sessions: If you run multiple BFF instances, either use sticky sessions at the load balancer or use a shared session store (Redis).
- Health checks: Expose a
/healthendpoint that the load balancer can monitor. - Environment variables: Never hardcode the client secret. Pass it via environment variables or a secrets manager.
For managed Keycloak hosting with built-in high availability, review Skycloak’s hosting options and pricing plans.
When to Use the BFF Pattern
The BFF pattern is the right choice when:
- Your SPA handles sensitive user data (financial, healthcare, PII).
- You need refresh tokens but cannot store them safely in the browser.
- You want centralized audit logging of authentication events on the server side.
- You are building a multi-tenant application where token security is critical.
For simpler applications with lower security requirements, the standard Authorization Code flow with PKCE may be sufficient. See our guide on securing React applications with Keycloak OIDC PKCE for that approach.
Wrapping Up
The BFF pattern moves the security boundary from the browser to a controlled server environment. By keeping tokens server-side and using HTTP-only cookies for session management, you eliminate an entire class of token-theft vulnerabilities.
Keycloak’s standards-based OAuth 2.0 and OpenID Connect implementation makes it straightforward to integrate with a BFF. The confidential client handles the token exchange securely, and the refresh token flow works seamlessly on the server side.
If you want to skip the infrastructure overhead of running Keycloak and a BFF yourself, Skycloak provides managed Keycloak hosting with built-in high availability, automated backups, and enterprise-grade security — so you can focus on building your application.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.