Set up a Backend-for-Frontend (BFF) using Azure Functions v4, Keycloak OAuth2, encrypted session cookies (@hapi/iron), CORS, CSRF protection, and a backend proxy deployed to Azure Static Web Apps. Use this skill whenever someone needs to: create a BFF, add server-side auth to an SPA, proxy API calls through Azure Functions, integrate Keycloak with a BFF, deploy Azure Functions alongside a frontend on Azure SWA, add login/logout endpoints, set up session cookies, secure API calls without tokens in the browser, add cookie-based auth, migrate from SPA-based OIDC or PKCE to a BFF, troubleshoot CORS/CSRF in a BFF, or set up server-side session management. Even if the user just says "add authentication" or "I don't want tokens in the browser", this skill applies.
This skill guides you through creating a complete BFF layer using Azure Functions v4 (TypeScript, ESM) that sits between an SPA frontend and a backend API. The BFF handles authentication via Keycloak, manages encrypted session cookies, enforces CSRF protection, and proxies API requests with bearer token injection.
The BFF pattern is the right choice when:
This BFF uses the ROPC grant (password grant) rather than Authorization Code flow. This means the SPA collects credentials via a custom login form and sends them to the BFF, which forwards them to Keycloak. This is appropriate when:
If your app is public-facing or must comply with OAuth 2.1 (which deprecates ROPC), use Authorization Code flow with PKCE instead. The BFF would then handle the redirect dance server-side. The Keycloak client must have "Direct Access Grants" enabled for ROPC.
Note: The reference files use blog-specific names (e.g.,
BACKEND_API_URL,proxy-entries). Adapt resource names, routes, and backend paths to your project's domain.
Frontend (SPA)
| fetch with credentials (cookies)
v
BFF (Azure Functions v4)
|-- Session Management (@hapi/iron sealed cookies)
|-- CSRF Validation (X-Requested-With header)
|-- CORS Handling (preflight + response headers)
|-- Token Management (Keycloak OAuth2 ROPC)
| \-- Auto-refresh on expiry
\-- Backend Proxy
\-- Attach bearer token --> Backend API
Create a bff/ directory at the project root with the structure below. Read references/project-setup.md for the exact file contents of package.json, tsconfig.json, host.json, and local.settings.json.
bff/
src/
index.ts # Entry point - imports all function files
lib/
session.ts # @hapi/iron seal/unseal + cookie helpers
keycloak.ts # OAuth2 ROPC auth, refresh, revoke
cors.ts # CORS preflight + response headers
csrf.ts # X-Requested-With header check
proxy.ts # Backend proxy with auto-refresh
functions/
auth-login.ts # POST /api/auth/login
auth-logout.ts # POST /api/auth/logout
auth-me.ts # GET /api/auth/me
auth-refresh.ts # POST /api/auth/refresh
proxy-*.ts # One file per proxied resource
package.json
tsconfig.json
host.json
local.settings.json # Not committed - env vars for local dev
Read references/lib-implementations.md when implementing this step -- it contains the exact source code for all five library files. Copy these as-is, then adapt environment variable names and backend URL patterns to the target project. The key design decisions:
session.ts - Uses @hapi/iron for symmetric encryption of { accessToken, refreshToken, expiresAt }. Cookie is httpOnly, Secure, SameSite=Lax. Includes decodeURIComponent() fallback because Azure SWA URL-encodes cookie values.
keycloak.ts - Resource Owner Password Credentials (ROPC) grant for login, refresh_token grant for renewal, token revocation for logout. Uses URLSearchParams for form-encoded bodies.
cors.ts - Every response includes Access-Control-Allow-Origin and Access-Control-Allow-Credentials: true. Preflight returns 204 with allowed methods and headers.
csrf.ts - Validates X-Requested-With: XMLHttpRequest on state-changing requests (POST, PUT). Returns 403 if missing.
proxy.ts - Extracts session from cookie, auto-refreshes if expired, attaches bearer token, forwards request to backend. Returns refreshed cookie if token was renewed.
Read references/function-implementations.md when implementing this step -- it contains complete source code for all auth and proxy functions. Adapt the proxy endpoints to your domain's resources. Every function follows this pattern:
async function handler(request: HttpRequest): Promise<HttpResponseInit> {
// 1. Handle CORS preflight
const preflight = handlePreflight(request);
if (preflight) return preflight;
// 2. Check CSRF (only for state-changing methods)
if (request.method === "POST" || request.method === "PUT") {
const csrfError = checkCsrf(request);
if (csrfError) return { ...csrfError, headers: corsHeaders }; // CORS on errors too!
}
// 3. Do the work (auth or proxy)
// 4. Return response with corsHeaders and cookies array
}
app.http("function-name", {
methods: ["POST", "OPTIONS"], // Always include OPTIONS
authLevel: "anonymous",
route: "your/route",
handler,
});
The entry point must import every function file so Azure Functions discovers them:
import "./functions/auth-login.js";
import "./functions/auth-logout.js";
// ... all other functions
This file is referenced by "main": "dist/index.js" in package.json.
The frontend needs two changes:
export const bffInterceptor: HttpInterceptorFn = (req, next) => {
if (req.url.startsWith(environment.bffUrl)) {
req = req.clone({
withCredentials: true,
setHeaders: { "X-Requested-With": "XMLHttpRequest" },
});
}
return next(req);
};
/api in production (same origin, served by Azure SWA) and http://localhost:7071/api in development (Azure Functions local runtime).Use concurrently to run the frontend dev server and BFF together:
{
"start": "concurrently \"ng serve\" \"npm run start:bff\"",
"start:bff": "cd bff && npm start"
}
Read references/deployment.md for the full deployment guide including GitHub Actions workflow config and environment variable setup.
After the user has deployed, ask them for their SWA app name and verify that all required environment variables are set. Do NOT set secrets — the user must run the az staticwebapp appsettings set command themselves since it involves credentials. Instead:
references/deployment.md with their actual valuesaz staticwebapp appsettings list --name <swa-name> --query "[].name" -o tsv
SESSION_SECRET, KEYCLOAK_URL, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, BACKEND_API_URL, ALLOWED_ORIGINaz command to set themThese are real bugs encountered during development and deployment. Each one cost significant debugging time:
Azure SWA silently drops Set-Cookie headers from managed function responses. Use the cookies: Cookie[] property on HttpResponseInit instead of setting headers manually. This is the single most painful bug in the entire BFF setup because the login appears to succeed (200 response with correct body) but no cookie is set.
@hapi/iron sealed tokens contain * characters (e.g., Fe26.2**...). Azure SWA encodes these to %2A. When the browser sends the cookie back, unseal() fails on the encoded string. Always decodeURIComponent() the cookie value before unsealing, with a try/catch fallback for already-decoded values.
When a CSRF check or session validation fails, the error response still needs CORS headers. Without them, the browser blocks the error response entirely and the frontend gets an opaque network error instead of a useful 401/403. Always spread corsHeaders into error responses.
jose (used for JWT decoding) is ESM-only. The BFF must have "type": "module" in package.json. All local imports need .js extensions (e.g., import { foo } from './session.js').
Two Azure Functions cannot share the same route, even with different HTTP methods. If you need GET and POST on /entries, use a single function file that handles both methods.
api_location in GitHub ActionsThe Azure SWA GitHub Actions workflow needs api_location: "bff" to deploy the managed functions. If left empty, the BFF code is silently not deployed.
The @types/hapi__iron package version ^6.0.6 does not exist on npm. Use ^6.0.1.
The X-Requested-With custom header triggers a CORS preflight (OPTIONS) request. Every endpoint that accepts state-changing requests must also accept OPTIONS and return proper CORS headers.
When adding a new proxied resource, follow this checklist:
bff/src/functions/proxy-<name>.tsproxyToBackend() with the backend pathapp.http() including OPTIONS in methodsbff/src/index.ts| Variable | Description | Example |
|---|---|---|
SESSION_SECRET | 32+ char secret for @hapi/iron | openssl rand -base64 32 |
KEYCLOAK_URL | Keycloak realm URL | https://keycloak.example.com/realms/myapp |
KEYCLOAK_CLIENT_ID | Confidential client ID | bff-myapp |
KEYCLOAK_CLIENT_SECRET | Client secret from Keycloak | R8jk2D8... |
BACKEND_API_URL | Backend API base URL | https://api.example.com |
ALLOWED_ORIGIN | Frontend origin for CORS | https://myapp.azurestaticapps.net |
For local development, set these in bff/local.settings.json (gitignored). For production, set them as Azure SWA Application Settings via az staticwebapp appsettings set.