Backend-For-Frontend (BFF) Pattern

Last updated: December 2025

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:

  1. Handle OAuth2/OIDC flow (authorization code + PKCE)
  2. Store refresh tokens securely (httpOnly cookies)
  3. Manage sessions for authenticated users
  4. Proxy API requests with access tokens

Step 2: Frontend Setup

Your frontend just needs to:

  1. Redirect to your backend’s login endpoint
  2. Handle the callback
  3. Make API calls to your backend (not directly to auth service)
  4. 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

  1. Use httpOnly Cookies – Store refresh tokens in httpOnly cookies to prevent XSS attacks
  2. Secure Cookies – Always use Secure flag in production (HTTPS only)
  3. SameSite Cookies – Use SameSite=Strict to prevent CSRF attacks
  4. Session Management – Use secure session storage with proper expiration
  5. Token Refresh – Implement automatic token refresh before expiry
  6. Error Handling – Don’t expose sensitive error messages to frontend
  7. Rate Limiting – Implement rate limiting on your BFF endpoints
  8. 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.com for 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) and https://myapp.com/api (backend)
  • Or use subdirectories: https://myapp.com/app (frontend) and https://myapp.com/api (backend)

Option 2: Subdomain with Cookie Domain

  • Use https://app.myapp.com (frontend) and https://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:

  1. Open Safari Developer Tools (Develop menu)
  2. Check the Storage tab β†’ Cookies
  3. Verify cookies are being set and sent
  4. 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