Backend-For-Frontend (BFF) Pattern
Want to keep your frontend simple and secure? The Backend-For-Frontend (BFF) pattern is your friend! π―
What is BFF?
The Backend-For-Frontend (BFF) pattern uses a backend server as a secure proxy between your frontend application and the authentication service. Instead of your frontend directly handling OAuth2/OIDC flows and tokens, your backend does all the heavy lifting and provides a simpler, more secure API.
Why Use BFF? π
Security Benefits:
- β No tokens in the browser – Access tokens never leave your backend
- β Secure refresh token storage – Refresh tokens stay in httpOnly cookies on your backend
- β Reduced XSS risk – Tokens aren’t exposed to JavaScript
- β Better token management – Backend handles all token refresh logic
Developer Experience:
- β Simpler frontend – No OAuth2/OIDC complexity in your frontend code
- β Standard HTTP sessions – Use familiar session-based auth
- β Easier debugging – All auth logic in one place (your backend)
- β Better error handling – Centralized error management
Architecture Benefits:
- β API gateway pattern – Your backend can proxy requests to multiple services
- β Request transformation – Add headers, modify requests before forwarding
- β Rate limiting – Implement rate limiting at the backend level
- β Caching – Cache user data and reduce API calls
Architecture Overview
βββββββββββββββ ββββββββββββββββ βββββββββββββββ
β Browser β β Your BFF β β Auth β
β (Frontend) β β (Backend) β β Service β
ββββββββ¬βββββββ ββββββββ¬ββββββββ ββββββββ¬βββββββ
β β β
β 1. Click "Login" β β
βββββββββββββββββββββββ>β β
β β β
β 2. Redirect to auth β β
β<βββββββββββββββββββββββ β
β β 3. OAuth2 redirect β
β βββββββββββββββββββββββββ>β
β β β
β 4. User authenticatesβ β
βββββββββββββββββββββββββββββββββββββββββββββββββ>β
β β β
β 5. Redirect with codeβ β
β<βββββββββββββββββββββββββββββββββββββββββββββββββ
β β β
β 6. Callback to BFF β β
βββββββββββββββββββββββ>β β
β β 7. Exchange code β
β βββββββββββββββββββββββββ>β
β β β
β β 8. Receive tokens β
β β<βββββββββββββββββββββββββ
β β β
β 9. Set session cookieβ β
β<βββββββββββββββββββββββ β
β β β
β 10. API calls with β β
β session cookie β β
βββββββββββββββββββββββ>β 11. Add Bearer token β
β βββββββββββββββββββββββββ>β
β β β
β 12. API response β β
β<βββββββββββββββββββββββ<βββββββββββββββββββββββββ
Implementation Guide
Step 1: Backend Setup
Your backend needs to:
- Handle OAuth2/OIDC flow (authorization code + PKCE)
- Store refresh tokens securely (httpOnly cookies)
- Manage sessions for authenticated users
- Proxy API requests with access tokens
Step 2: Frontend Setup
Your frontend just needs to:
- Redirect to your backend’s login endpoint
- Handle the callback
- Make API calls to your backend (not directly to auth service)
- Let your backend handle all token management
Complete Example: Node.js/Express BFF
Backend Implementation
// server.js
const express = require('express');
const session = require('express-session');
const cookieParser = require('cookie-parser');
const { UserManager } = require('oidc-client-ts');
const fetch = require('node-fetch');
const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true }
}));
const tenantId = process.env.TENANT_ID;
const BASE_URL = `https://auth.agglestone.com/tenant/${tenantId}`;
// Configure OIDC client for backend
const userManager = new UserManager({
authority: `${BASE_URL}/v2.0/Auth`,
client_id: tenantId,
redirect_uri: `${process.env.BFF_URL}/auth/callback`,
response_type: 'code',
scope: 'openid profile email',
automaticSilentRenew: false // We'll handle refresh manually
});
// Login endpoint - redirects to auth service
app.get('/auth/login', async (req, res) => {
try {
await userManager.signinRedirect();
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Login failed' });
}
});
// Callback endpoint - handles OAuth2 callback
app.get('/auth/callback', async (req, res) => {
try {
const user = await userManager.signinRedirectCallback();
// Store tokens in session (access token) and httpOnly cookie (refresh token)
req.session.accessToken = user.access_token;
req.session.userId = user.profile.sub;
req.session.userProfile = user.profile;
// Store refresh token in httpOnly cookie
res.cookie('refreshToken', user.refresh_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
});
// Redirect to frontend
res.redirect(process.env.FRONTEND_URL);
} catch (error) {
console.error('Callback error:', error);
res.status(500).json({ error: 'Authentication failed' });
}
});
// Middleware to ensure user is authenticated
async function requireAuth(req, res, next) {
if (!req.session.accessToken) {
return res.status(401).json({ error: 'Not authenticated' });
}
// Check if token is expired (simplified - decode JWT to check exp)
// For now, try to refresh if we get a 401
next();
}
// Get access token (with automatic refresh)
async function getAccessToken(req) {
let accessToken = req.session.accessToken;
// If no access token, try to refresh using refresh token from cookie
if (!accessToken && req.cookies.refreshToken) {
const newTokens = await refreshAccessToken(req.cookies.refreshToken);
req.session.accessToken = newTokens.access_token;
req.cookies.refreshToken = newTokens.refresh_token; // Update cookie
accessToken = newTokens.access_token;
}
return accessToken;
}
// Refresh access token
async function refreshAccessToken(refreshToken) {
const formData = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: tenantId
});
const response = await fetch(`${BASE_URL}/v2.0/Auth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: formData
});
if (!response.ok) {
throw new Error('Token refresh failed');
}
return await response.json();
}
// Proxy endpoint - forwards requests to auth service with Bearer token
app.use('/api/*', requireAuth, async (req, res) => {
try {
const accessToken = await getAccessToken(req);
// Forward request to auth service
const targetUrl = `${BASE_URL}${req.path}`;
const response = await fetch(targetUrl, {
method: req.method,
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
...req.headers
},
body: req.method !== 'GET' ? JSON.stringify(req.body) : undefined
});
const data = await response.json();
// If 401, try to refresh token and retry
if (response.status === 401 && req.cookies.refreshToken) {
const newTokens = await refreshAccessToken(req.cookies.refreshToken);
req.session.accessToken = newTokens.access_token;
// Retry request
const retryResponse = await fetch(targetUrl, {
method: req.method,
headers: {
'Authorization': `Bearer ${newTokens.access_token}`,
'Content-Type': 'application/json'
},
body: req.method !== 'GET' ? JSON.stringify(req.body) : undefined
});
const retryData = await retryResponse.json();
return res.status(retryResponse.status).json(retryData);
}
res.status(response.status).json(data);
} catch (error) {
console.error('Proxy error:', error);
res.status(500).json({ error: 'Proxy request failed' });
}
});
// Get current user info
app.get('/auth/me', requireAuth, (req, res) => {
res.json({
userId: req.session.userId,
profile: req.session.userProfile
});
});
// Logout
app.post('/auth/logout', async (req, res) => {
// Clear session
req.session.destroy();
// Clear refresh token cookie
res.clearCookie('refreshToken');
// Optionally call logout endpoint on auth service
if (req.session.accessToken) {
// Call logout endpoint if needed
}
res.json({ success: true });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`BFF server running on port ${PORT}`);
});
Frontend Implementation
// frontend/auth.js
const BFF_URL = 'http://localhost:3000';
// Login - just redirect to BFF
export function login() {
window.location.href = `${BFF_URL}/auth/login`;
}
// Check if authenticated
export async function isAuthenticated() {
try {
const response = await fetch(`${BFF_URL}/auth/me`, {
credentials: 'include' // Include cookies
});
return response.ok;
} catch (error) {
return false;
}
}
// Get current user
export async function getCurrentUser() {
const response = await fetch(`${BFF_URL}/auth/me`, {
credentials: 'include'
});
if (!response.ok) {
return null;
}
return await response.json();
}
// Make API calls through BFF
export async function apiCall(endpoint, options = {}) {
const response = await fetch(`${BFF_URL}/api${endpoint}`, {
...options,
credentials: 'include', // Include session cookie
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
if (response.status === 401) {
// Not authenticated, redirect to login
login();
return;
}
return response.json();
}
// Logout
export async function logout() {
await fetch(`${BFF_URL}/auth/logout`, {
method: 'POST',
credentials: 'include'
});
// Redirect to login
window.location.href = '/login';
}
// Usage example
async function fetchUsers() {
const users = await apiCall('/Users?pageNumber=1&pageSize=20');
console.log('Users:', users);
}
React Example
// App.jsx
import { useEffect, useState } from 'react';
import { login, getCurrentUser, apiCall } from './auth';
function App() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
checkAuth();
}, []);
async function checkAuth() {
const currentUser = await getCurrentUser();
if (currentUser) {
setUser(currentUser);
} else {
// Not authenticated - could redirect to login
}
setLoading(false);
}
if (loading) {
return <div>Loading...</div>;
}
if (!user) {
return (
<div>
<h1>Please log in</h1>
<button onClick={login}>Login</button>
</div>
);
}
return (
<div>
<h1>Welcome, {user.profile.name}!</h1>
<UserList />
</div>
);
}
function UserList() {
const [users, setUsers] = useState([]);
useEffect(() => {
loadUsers();
}, []);
async function loadUsers() {
const data = await apiCall('/Users?pageNumber=1&pageSize=20');
setUsers(data.data);
}
return (
<ul>
{users.map(user => (
<li key={user.userId}>{user.displayName}</li>
))}
</ul>
);
}
C# / .NET BFF Example
// Startup.cs or Program.cs
using Microsoft.AspNetCore.Authentication.Cookies;
using IdentityModel.OidcClient;
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/auth/login";
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});
services.AddSession();
services.AddHttpClient();
// Configure OIDC client
var tenantId = Configuration["TenantId"];
var baseUrl = $"https://auth.agglestone.com/tenant/{tenantId}";
services.AddSingleton<OidcClient>(sp =>
{
var options = new OidcClientOptions
{
Authority = $"{baseUrl}/v2.0/Auth",
ClientId = tenantId,
RedirectUri = $"{Configuration["BffUrl"]}/auth/callback",
Scope = "openid profile email"
};
return new OidcClient(options);
});
}
}
// AuthController.cs
[ApiController]
[Route("auth")]
public class AuthController : ControllerBase
{
private readonly OidcClient _oidcClient;
private readonly IHttpClientFactory _httpClientFactory;
public AuthController(OidcClient oidcClient, IHttpClientFactory httpClientFactory)
{
_oidcClient = oidcClient;
_httpClientFactory = httpClientFactory;
}
[HttpGet("login")]
public async Task<IActionResult> Login()
{
var result = await _oidcClient.LoginAsync();
if (result.IsError)
{
return BadRequest(result.Error);
}
// Store tokens in session
HttpContext.Session.SetString("AccessToken", result.AccessToken);
HttpContext.Session.SetString("RefreshToken", result.RefreshToken);
HttpContext.Session.SetString("UserId", result.User.Identity.Name);
return Redirect("/");
}
[HttpGet("callback")]
public async Task<IActionResult> Callback()
{
// OidcClient handles callback automatically in LoginAsync
return Redirect("/");
}
[HttpGet("me")]
[Authorize]
public IActionResult GetMe()
{
return Ok(new
{
UserId = HttpContext.Session.GetString("UserId"),
IsAuthenticated = User.Identity.IsAuthenticated
});
}
}
// ProxyController.cs
[ApiController]
[Route("api")]
[Authorize]
public class ProxyController : ControllerBase
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _configuration;
public ProxyController(IHttpClientFactory httpClientFactory, IConfiguration configuration)
{
_httpClientFactory = httpClientFactory;
_configuration = configuration;
}
[HttpGet("{*path}")]
[HttpPost("{*path}")]
[HttpPut("{*path}")]
[HttpDelete("{*path}")]
public async Task<IActionResult> Proxy(string path)
{
var accessToken = HttpContext.Session.GetString("AccessToken");
if (string.IsNullOrEmpty(accessToken))
{
return Unauthorized();
}
var tenantId = _configuration["TenantId"];
var baseUrl = $"https://auth.agglestone.com/tenant/{tenantId}";
var targetUrl = $"{baseUrl}/api/{path}";
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
var request = new HttpRequestMessage(
new HttpMethod(Request.Method),
targetUrl
);
if (Request.Method != "GET" && Request.Body != null)
{
request.Content = new StreamContent(Request.Body);
request.Content.Headers.ContentType =
new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
}
var response = await client.SendAsync(request);
var content = await response.Content.ReadAsStringAsync();
return new ContentResult
{
StatusCode = (int)response.StatusCode,
Content = content,
ContentType = response.Content.Headers.ContentType?.ToString()
};
}
}
Python BFF Example (Flask)
# app.py
from flask import Flask, session, redirect, request, jsonify, make_response
from authlib.integrations.requests_client import OAuth2Session
import os
app = Flask(__name__)
app.secret_key = os.getenv('SESSION_SECRET')
tenant_id = os.getenv('TENANT_ID')
base_url = f"https://auth.agglestone.com/tenant/{tenant_id}"
bff_url = os.getenv('BFF_URL', 'http://localhost:5000')
# OAuth2 client
oauth = OAuth2Session(
client_id=tenant_id,
redirect_uri=f"{bff_url}/auth/callback",
scope='openid profile email'
)
@app.route('/auth/login')
def login():
authorization_url, state = oauth.authorization_url(
f"{base_url}/v2.0/Auth/authorize"
)
session['oauth_state'] = state
return redirect(authorization_url)
@app.route('/auth/callback')
def callback():
token = oauth.fetch_token(
f"{base_url}/v2.0/Auth/token",
authorization_response=request.url,
state=session.get('oauth_state')
)
session['access_token'] = token['access_token']
session['refresh_token'] = token['refresh_token']
# Store refresh token in httpOnly cookie
response = make_response(redirect('/'))
response.set_cookie(
'refresh_token',
token['refresh_token'],
httponly=True,
secure=True,
samesite='Strict',
max_age=30*24*60*60 # 30 days
)
return response
@app.route('/auth/me')
def me():
if 'access_token' not in session:
return jsonify({'error': 'Not authenticated'}), 401
return jsonify({
'userId': session.get('user_id'),
'authenticated': True
})
@app.route('/api/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE'])
def proxy(path):
if 'access_token' not in session:
return jsonify({'error': 'Not authenticated'}), 401
import requests
target_url = f"{base_url}/api/{path}"
headers = {
'Authorization': f"Bearer {session['access_token']}",
'Content-Type': 'application/json'
}
response = requests.request(
request.method,
target_url,
headers=headers,
params=request.args,
json=request.get_json() if request.is_json else None
)
if response.status_code == 401:
# Try to refresh token
refresh_token = request.cookies.get('refresh_token')
if refresh_token:
new_token = refresh_access_token(refresh_token)
session['access_token'] = new_token['access_token']
# Retry request
response = requests.request(
request.method,
target_url,
headers={'Authorization': f"Bearer {new_token['access_token']}"},
params=request.args,
json=request.get_json() if request.is_json else None
)
return make_response(
response.content,
response.status_code,
{'Content-Type': 'application/json'}
)
def refresh_access_token(refresh_token):
import requests
response = requests.post(
f"{base_url}/v2.0/Auth/token",
data={
'grant_type': 'refresh_token',
'refresh_token': refresh_token,
'client_id': tenant_id
}
)
return response.json()
if __name__ == '__main__':
app.run(debug=True)
Security Best Practices
- Use httpOnly Cookies – Store refresh tokens in httpOnly cookies to prevent XSS attacks
- Secure Cookies – Always use
Secureflag in production (HTTPS only) - SameSite Cookies – Use
SameSite=Strictto prevent CSRF attacks - Session Management – Use secure session storage with proper expiration
- Token Refresh – Implement automatic token refresh before expiry
- Error Handling – Don’t expose sensitive error messages to frontend
- Rate Limiting – Implement rate limiting on your BFF endpoints
- HTTPS Only – Always use HTTPS in production
Browser Compatibility: Safari Cookie Restrictions
> β οΈ Important for Safari Users: Safari has strict cookie policies that can affect your BFF implementation!
The Rule
Safari blocks cookies if your frontend and backend are on different domains. This is Safari’s Intelligent Tracking Prevention (ITP) in action.
What This Means
β Won’t Work:
- Frontend:
https://myapp.com(user’s browser) - Backend:
https://api.myapp.com(your BFF server) - Result: Safari will block cookies, breaking your session management
β Will Work:
- Frontend:
https://myapp.com(user’s browser) - Backend:
https://myapp.com(your BFF server – same domain!) - Result: Cookies work perfectly, even if you redirect to
https://auth.agglestone.comfor authentication
The Good News π
The authentication service domain can be completely different! As long as your frontend and backend share the same domain, Safari is happy. The OAuth2 redirect to https://auth.agglestone.com and back works fine – Safari only cares about the domain relationship between your frontend and backend.
Solutions
Option 1: Same Domain (Recommended)
- Deploy your frontend and backend on the same domain
- Use path-based routing:
https://myapp.com(frontend) andhttps://myapp.com/api(backend) - Or use subdirectories:
https://myapp.com/app(frontend) andhttps://myapp.com/api(backend)
Option 2: Subdomain with Cookie Domain
- Use
https://app.myapp.com(frontend) andhttps://api.myapp.com(backend) - Set cookie domain to
.myapp.com(note the leading dot) - This works but requires careful cookie configuration
Option 3: Proxy/Reverse Proxy
- Use a reverse proxy (nginx, Cloudflare, etc.) to route requests
- Frontend and backend appear on the same domain to the browser
- Backend can be on a different server internally
Example: Same-Domain Setup
// Frontend: https://myapp.com
// Backend: https://myapp.com/api
// Frontend makes requests to same domain
const response = await fetch('/api/users', {
credentials: 'include' // Cookies work!
});
// Backend sets cookies on same domain
res.cookie('refreshToken', token, {
domain: 'myapp.com', // Same domain = Safari happy! β
httpOnly: true,
secure: true
});
Testing in Safari
If you’re experiencing cookie issues:
- Open Safari Developer Tools (Develop menu)
- Check the Storage tab β Cookies
- Verify cookies are being set and sent
- Check the Console for cookie-related warnings
Remember: Same frontend/backend domain = Safari compatibility! The auth service domain doesn’t matter. π―
When to Use BFF
β Use BFF when:
- You want maximum security (no tokens in browser)
- You have a backend server anyway
- You want simpler frontend code
- You need to proxy requests to multiple services
- You want centralized rate limiting and caching
β Skip BFF when:
- You’re building a pure SPA with no backend
- You want the simplest possible architecture
- You’re comfortable managing tokens in the browser
- You’re using a serverless architecture without a persistent backend
Next Steps
- Review the Integration Guide for direct OAuth2/OIDC implementation
- Check Code Examples for more implementation patterns
- Learn about API Keys vs JWT Tokens for server-to-server communication