BFF & API Patterns
Master the Backend-for-Frontend architecture: proxy routing, session management, CSRF protection, error handling, and LLM proxy capabilities.
📋Learning Objectives
- Understand BFF architecture and responsibilities
- Configure proxy routes to backend services
- Master session management with secure cookies
- Implement CSRF protection in plugins
- Handle API errors consistently
- Use the LLM proxy for AI features
🏗️BFF Architecture
The BFF sits between the Experience Platform and backend services, providing security, routing, and aggregation.
BFF Responsibilities
Proxy Routing
Routes requests to appropriate backend services
Authentication
Handles OIDC flows, stores tokens securely
Session Management
Manages secure httpOnly cookies + Redis
CSRF Protection
Validates tokens for state-changing requests
LLM Proxy
Multi-provider AI routing with cost tracking
Aggregation
Combine multiple backend calls into one response
🔀Proxy Routing
The BFF routes requests based on URL path prefixes. Each route maps to a backend service.
Route Configuration
# BFF proxy configuration (bff/config/routes.yaml)
routes:
- path: /api/membership/**
target: http://membership:8003
strip_prefix: true
auth_required: true
rate_limit:
requests: 100
window: 60s
- path: /api/pdp/**
target: http://pdp:8001
strip_prefix: true
auth_required: true
- path: /api/idp/**
target: http://idp:8002
strip_prefix: true
auth_required: false # IdP handles its own auth
- path: /api/crud/**
target: http://crudservice:8004
strip_prefix: true
auth_required: true
inject_headers:
X-Request-ID: "{{ request_id }}"
X-User-ID: "{{ user.sub }}"
How Routing Works
| Plugin Request | BFF Routes To |
|---|---|
/api/membership/users/me/roles |
http://membership:8003/users/me/roles |
/api/pdp/access/v1/evaluation |
http://pdp:8001/access/v1/evaluation |
/api/idp/userinfo |
http://idp:8002/userinfo |
/api/crud/workflows/run |
http://crudservice:8004/workflows/run |
Plugin API Allowlist
Plugins can only call endpoints explicitly allowed in their plugins.yaml:
# plugins.yaml - API permissions
permissions:
api:
- method: GET
path: /api/membership/users/{id}/roles
- method: GET
path: /api/membership/roles/{id}
- method: POST
path: /access/v1/evaluation
- method: POST
path: /access/v1/evaluations
sse:
- /api/events/membership/roles
If a plugin calls an endpoint not in its permissions.api list, the BFF returns 403 Forbidden. Always add new endpoints to plugins.yaml before using them.
🍪Session Management
The BFF manages user sessions with secure, httpOnly cookies backed by Redis.
Session Configuration
# Session settings
session:
cookie_name: empowernow_session
cookie_secure: true # HTTPS only
cookie_httponly: true # No JavaScript access
cookie_samesite: lax # CSRF protection
max_age: 86400 # 24 hours
store:
type: redis
url: redis://redis:6379/1
prefix: session:
ttl: 86400 # Match cookie max_age
Token Refresh
The BFF automatically refreshes tokens before they expire:
// BFF token refresh logic (simplified)
async function getTokensForSession(sessionId: string) {
const session = await redis.get(`session:${sessionId}`);
// Check if access token expires within 60 seconds
if (session.access_token_expires_at - Date.now() < 60000) {
// Refresh tokens
const newTokens = await idpClient.refreshTokens(session.refresh_token);
// Update session
await redis.set(`session:${sessionId}`, {
...session,
access_token: newTokens.access_token,
access_token_expires_at: Date.now() + newTokens.expires_in * 1000
});
return newTokens.access_token;
}
return session.access_token;
}
Tokens are stored in httpOnly cookies—JavaScript can't access them. This protects against XSS attacks. The SDK's sdk.api.fetch() automatically includes cookies with every request.
🛡️CSRF Protection
Cross-Site Request Forgery attacks trick users into making unwanted requests. The BFF requires CSRF tokens for state-changing operations.
How CSRF Protection Works
- BFF generates a CSRF token and stores it in the session
- Token is returned in a response header or cookie
- Plugin includes token in
X-CSRF-Tokenheader for POST/PUT/DELETE - BFF validates token matches session
Automatic CSRF with sdk.api.fetch()
// The SDK handles CSRF automatically!
const sdk = (globalThis as any).pluginSdk;
// POST request - CSRF token automatically included
const response = await sdk.api.fetch('/api/crud/workflows/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflow: 'access-request',
input: { resource_id: 'res-123' }
})
});
// GET requests don't need CSRF tokens
const data = await sdk.api.fetch('/api/membership/users/me/roles');
Manual CSRF (rare cases)
// If you need to make a raw fetch (not recommended)
const getCsrfToken = async () => {
const response = await fetch('/api/csrf-token', {
credentials: 'include'
});
const data = await response.json();
return data.csrf_token;
};
const manualPost = async (url: string, body: any) => {
const csrfToken = await getCsrfToken();
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken // Must include for POST
},
body: JSON.stringify(body),
credentials: 'include' // Include session cookie
});
};
If you make a POST/PUT/DELETE without a valid CSRF token, the BFF returns 403. Always use sdk.api.fetch() which handles this automatically.
⚠️Error Handling Patterns
Consistent error handling makes your plugin robust and user-friendly.
Standard Error Response
// BFF returns standardized error format
{
"error": {
"code": "PERMISSION_DENIED",
"message": "You don't have permission to access this resource",
"details": {
"required_permission": "documents:read",
"resource": "/api/documents/doc-123"
},
"request_id": "req-abc-123" // For debugging
}
}
Handling Errors in Plugins
// Comprehensive error handling
const sdk = (globalThis as any).pluginSdk;
async function fetchWithErrorHandling(url: string) {
try {
const response = await sdk.api.fetch(url);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
switch (response.status) {
case 401:
// Session expired - redirect to login
window.location.href = '/auth/login?returnTo=' + encodeURIComponent(window.location.pathname);
throw new Error('Session expired');
case 403:
// Permission denied
const msg = errorData.error?.message || 'Access denied';
throw new Error(msg);
case 404:
throw new Error('Resource not found');
case 429:
// Rate limited - show retry message
const retryAfter = response.headers.get('Retry-After') || '60';
throw new Error(`Rate limited. Retry after ${retryAfter} seconds`);
case 500:
case 502:
case 503:
throw new Error('Service temporarily unavailable');
default:
throw new Error(errorData.error?.message || `HTTP ${response.status}`);
}
}
return await response.json();
} catch (err: any) {
if (err.name === 'TypeError' && err.message.includes('fetch')) {
// Network error
throw new Error('Network error. Check your connection.');
}
throw err;
}
}
HTTP Status Code Reference
| Status | Meaning | Plugin Action |
|---|---|---|
401 |
Unauthorized (session invalid) | Redirect to login |
403 |
Forbidden (no permission) | Show "access denied" message |
404 |
Not found | Show "not found" state |
422 |
Validation error | Show field-level errors |
429 |
Rate limited | Show retry message, implement backoff |
5xx |
Server error | Show generic error, retry option |
🤖LLM Proxy
The BFF includes an LLM proxy for AI-powered features with multi-provider support and cost tracking.
LLM Proxy Features
- Multi-provider: OpenAI, Anthropic, Azure OpenAI
- Dynamic routing: Automatic fallback if primary provider fails
- Budget enforcement: Switch to cheaper models when budget exceeded
- Cost tracking: Track spending per user/tenant with receipts
- Streaming: SSE support for real-time responses
Using the LLM Proxy
// Call LLM via BFF proxy
const chatCompletion = async (messages: any[]) => {
const response = await sdk.api.fetch('/api/llm/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-4o', // Or 'claude-3-sonnet', etc.
messages: messages,
temperature: 0.7,
max_tokens: 1000
})
});
const data = await response.json();
return data.choices[0].message.content;
};
// Streaming responses
const streamChat = async (messages: any[], onChunk: (text: string) => void) => {
const response = await sdk.api.fetch('/api/llm/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-4o',
messages: messages,
stream: true
})
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
// Parse SSE format
const lines = chunk.split('\n').filter(line => line.startsWith('data: '));
for (const line of lines) {
const json = JSON.parse(line.slice(6));
if (json.choices?.[0]?.delta?.content) {
onChunk(json.choices[0].delta.content);
}
}
}
};
Learn more about LLM proxy configuration, AI agent authorization (ARIA), budget management, and cost tracking in Week 5, Days 22-23.
🔧Troubleshooting
Issue: 403 on API calls
# Most common causes:
# 1. Endpoint not in plugins.yaml permissions.api
# 2. Missing CSRF token for POST/PUT/DELETE
# 3. Session expired
# Check plugins.yaml has the endpoint:
permissions:
api:
- method: POST
path: /api/crud/workflows/run # Add this
# Verify using sdk.api.fetch (handles CSRF):
await sdk.api.fetch('/api/crud/workflows/run', {
method: 'POST',
body: JSON.stringify(data)
});
Issue: Session cookie not sent
# Symptom: 401 on every request
# Cause: credentials: 'include' missing in fetch
# Wrong:
fetch('/api/membership/users/me')
# Correct (use SDK):
sdk.api.fetch('/api/membership/users/me')
# Or with raw fetch:
fetch('/api/membership/users/me', { credentials: 'include' })
Issue: CORS errors
# Symptom: "No 'Access-Control-Allow-Origin' header"
# Cause: Calling backend directly instead of through BFF
# Wrong (direct to backend):
fetch('http://membership:8003/users/me')
# Correct (through BFF):
sdk.api.fetch('/api/membership/users/me')
Issue: Rate limited (429)
# Implement exponential backoff
const fetchWithRetry = async (url: string, maxRetries = 3) => {
for (let i = 0; i < maxRetries; i++) {
const response = await sdk.api.fetch(url);
if (response.status !== 429) {
return response;
}
const retryAfter = parseInt(response.headers.get('Retry-After') || '1');
await new Promise(r => setTimeout(r, retryAfter * 1000 * Math.pow(2, i)));
}
throw new Error('Rate limit exceeded after retries');
};
❓Knowledge Check
Tokens are stored in httpOnly cookies, which JavaScript cannot access. This protects against XSS attacks—even if malicious script runs in the browser, it can't steal the token. The BFF retrieves tokens from Redis using the session ID from the cookie.
CSRF tokens are required for state-changing requests: POST, PUT, PATCH, DELETE. GET requests don't need them because they should be safe (no side effects). The SDK's sdk.api.fetch() handles CSRF automatically.
The BFF returns 403 Forbidden. The plugin allowlist in plugins.yaml acts as a security boundary—plugins can only call explicitly permitted endpoints. This prevents plugins from accessing sensitive APIs they shouldn't.
Based on route config, the BFF:
- Matches path
/api/membership/** - Strips prefix
/api/membership - Forwards to
http://membership:8003/users/me - Adds Authorization header with user's access token
- Security: API keys never exposed to browser
- Multi-provider: Switch between OpenAI/Anthropic transparently
- Budget control: Enforce spending limits per user/tenant
- Cost tracking: Detailed receipts and analytics
- Fallback: Automatic failover to backup providers
📝Day 19 Checkpoint
- Understand BFF architecture and responsibilities
- Know how proxy routing works
- Understand session management with cookies + Redis
- Know when CSRF tokens are required
- Can handle API errors consistently
- Know how to use the LLM proxy
The BFF is the security gateway for all plugin API calls. It handles authentication, session management, CSRF protection, and provides a unified API surface. Understanding these patterns is essential for building secure, production-ready plugins.