A Backend-for-Frontend (BFF) and API Gateway built with Express 5 and Node.js (>=20.11). Handles OIDC authentication via Authentik, Redis-backed session management, and dynamic API proxying to backend services.
- Features
- Architecture
- Request Flow
- Project Structure
- Getting Started
- API Reference
- Authentication Flow (OIDC + PKCE)
- Session Management
- Token Refresh
- API Proxying
- Security
- Backend Health Monitoring
- Logging
- Docker Deployment
- Testing
- NPM Scripts
- License
- OIDC Authentication with PKCE (Proof Key for Code Exchange) via Authentik
- Redis-backed sessions with 8-hour rolling TTL
- Automatic token refresh when access tokens near expiry
- Dynamic API gateway routing requests to backends based on host, port, and path prefix
- Authenticated user header injection (
x-user-email,x-user-sub,x-user-name) into proxied requests - Admin API for live config updates, config reloading, and backend health inspection
- Backend health monitoring with periodic checks and downtime notifications
- Security hardened with Helmet, CORS, rate limiting, httpOnly cookies, and header spoofing prevention
- Structured logging with Pino (JSON in production, pretty-printed in development)
- WebSocket proxying with session-authenticated upgrades and user header injection
- Docker-ready with multi-service Compose (Redis + BFF)
- Static file serving with HTML fallback for SPA frontends
The BFF sits between frontend clients and backend services. It authenticates users via OIDC, manages sessions in Redis, and proxies API requests to the appropriate backend while injecting trusted user identity headers.
┌─────────────┐
│ Authentik │
│ (OIDC IdP) │
└──────┬───────┘
│
┌────────┐ ┌──────┴───────┐ ┌──────────────┐
│ Client ├───►│ BFF / API ├───►│ Backend A │
│ (SPA) │◄───┤ Gateway │ └──────────────┘
└────────┘ │ │ ┌──────────────┐
│ Express 5 ├───►│ Backend B │
│ + Redis │ └──────────────┘
└──────────────┘
The Express middleware stack is ordered for correctness — proxy routes are mounted before body parsers to preserve raw request bodies for proxying.
- Helmet — Security headers
- Pino HTTP — Structured request logging
- Morgan — HTTP logging (development)
- CORS — Origin validation
- Cookie Parser — Session cookie extraction
- Proxy Routes (
/api/*) — Mounted before body parsers - JSON Body Parser — 100KB limit
- URL-Encoded Body Parser — Form submissions
- Rate Limiter — 60 req/min on
/auth/endpoints - Static Files — Serve
public/with HTML fallback - Route Handlers — Auth, API, admin, health
- 404 Handler —
{"error": "Not Found"} - Global Error Handler — Catch-all with 500 response
src/
├── app.js # Express app with full middleware stack
├── server.js # HTTP server entry point
├── config/
│ ├── index.js # Environment variable loading & schema
│ └── proxyConfig.js # config.yml loading, validation, live reload
├── routes/
│ ├── auth.js # OIDC login, callback, logout
│ ├── api.js # /whoami/me user info endpoint
│ ├── proxy_routes.js # /api/* dynamic proxy middleware
│ ├── admin.js # /admin config & health endpoints
│ └── health.js # /healthz liveness check
├── middleware/
│ ├── requireAuth.js # Session validation + auto token refresh
│ └── csrfCheck.js # Origin/Referer CSRF validation
├── services/
│ ├── oidc.js # Token exchange, ID token verification (JWKS), refresh
│ ├── sessionStore.js # Redis session CRUD (8h rolling TTL, 10m state records)
│ ├── proxy.js # Backend resolution & http-proxy-middleware setup
│ └── healthChecker.js # Periodic backend health checks & notifications
└── utils/
├── logger.js # Pino logger (JSON prod / pretty dev)
└── crypto.js # PKCE pair generation, random string utilities
- Node.js >= 20.11
- Redis 7+
- Authentik (or any OIDC-compliant identity provider)
git clone <repository-url>
cd api-gateway
npm installCopy .env.example to .env and populate with your values:
cp .env.example .env| Variable | Required | Default | Description |
|---|---|---|---|
PORT |
No | 8080 |
Server listen port |
APP_BASE_URL |
Yes | — | External BFF URL (used for redirects and Origin checks) |
NODE_ENV |
No | development |
development or production |
LOG_LEVEL |
No | info |
Pino log level |
| Redis | |||
REDIS_URL |
Yes | — | Redis connection string (e.g., redis://redis:6379) |
| OIDC | |||
OIDC_ISSUER |
Yes | — | OIDC issuer URL |
OIDC_AUTHORIZATION_ENDPOINT |
Yes | — | Authorization endpoint |
OIDC_TOKEN_ENDPOINT |
Yes | — | Token endpoint |
OIDC_USERINFO_ENDPOINT |
Yes | — | Userinfo endpoint |
OIDC_JWKS_URI |
Yes | — | JWKS endpoint for ID token signature verification |
OIDC_CLIENT_ID |
Yes | — | OAuth2 client ID |
OIDC_CLIENT_SECRET |
Yes | — | OAuth2 client secret |
OIDC_REDIRECT_PATH |
Yes | — | Callback path (e.g., /auth/callback) |
OIDC_SCOPES |
No | openid profile email offline_access |
OAuth2 scopes |
OIDC_REVOCATION_ENDPOINT |
No | — | Token revocation endpoint |
ID_TOKEN_MAX_AGE_SECONDS |
No | 0 (disabled) |
Max allowed age of ID token during verification |
TOKEN_REFRESH_SKEW_SECONDS |
No | 60 |
Refresh token if < N seconds to expiry |
| Cookie | |||
SESSION_COOKIE_NAME |
No | sid |
Session cookie name |
SESSION_COOKIE_DOMAIN |
No | — | Cookie domain (leave blank for host-only) |
SESSION_COOKIE_SECURE |
No | true |
Require HTTPS for cookie |
SESSION_COOKIE_SAMESITE |
No | none |
Cookie SameSite attribute (Lax, Strict, None) |
| Security | |||
ALLOWED_ORIGINS |
No | — | Comma-separated allowed origins (merged with config.yml) |
The config.yml file at the project root defines backend routing and allowed origins:
defaultBackend: "http://backend-service:4070" # fallback when no mapping matches
allowedOrigins:
- "https://app.example.com"
- "https://admin.example.com"
mappings:
- frontendHost: "app.example.com"
backend: "http://backend-a:7001"
- frontendHost: "api.example.com"
frontendPort: 443 # optional: match specific port
pathPrefix: "/v2" # optional: match URL path prefix
backend: "http://backend-b:8080"Mapping fields:
| Field | Required | Description |
|---|---|---|
frontendHost |
Yes | Hostname from the request Host header to match |
backend |
Yes | Target backend URL to proxy to |
frontendPort |
No | Match requests only from this port |
pathPrefix |
No | Match requests whose URL starts with this path |
This config can be updated at runtime via the Admin API.
Development:
npm run devProduction:
NODE_ENV=production npm startDocker:
docker-compose upThis starts both Redis 7 (with AOF persistence) and the BFF service.
Initiates the OIDC authorization code flow with PKCE.
Query Parameters:
| Param | Required | Description |
|---|---|---|
next |
No | Post-login redirect path (must start with /) |
frontend_host |
No | Frontend origin for redirect; falls back to Origin/Referer headers |
Response: 302 redirect to the OIDC provider's authorization endpoint.
Behavior:
- Validates
frontend_hostagainst the allowed origins list - Generates a PKCE code verifier/challenge pair and a nonce
- Creates a state record in Redis (10-minute TTL) with
codeVerifier,nonce,next, andreturnToHost - Redirects to the OIDC authorization endpoint with
response_type=code,code_challenge,nonce, and configured scopes
Handles the OIDC provider callback after user authentication. The route path is configurable via OIDC_REDIRECT_PATH.
Query Parameters: code, state (provided by the OIDC provider)
Response: 302 redirect to {returnToHost}{next} (or / if no next).
Behavior:
- Retrieves and deletes the state record from Redis
- Exchanges the authorization code for tokens using the PKCE code verifier
- Verifies the ID token (signature via JWKS, issuer, audience, nonce, expiry, issued-at)
- Extracts user info (email, sub, name) from the ID token
- Creates a session in Redis (8-hour rolling TTL)
- Sets an httpOnly session cookie
- Redirects to the frontend
Error Responses:
400— Missingstateorcode400— Invalid or expired state502— ID token missing or verification failed
Destroys the user session.
Response: 204 No Content
Behavior:
- Deletes the session from Redis (best-effort)
- Clears the session cookie
Returns the authenticated user's profile from the OIDC userinfo endpoint.
Response:
{
"sub": "user-uuid",
"name": "Jane Doe",
"email": "jane@example.com",
"picture": "https://..."
}Error Responses:
401— Unauthorized (no valid session)502— Failed to fetch userinfo from OIDC provider
All requests to /api/* are authenticated and proxied to the resolved backend service.
- The
/apiprefix is stripped before forwarding (e.g.,/api/v1/usersbecomes/v1/users) - Trusted user headers are injected into the request (see User Header Injection)
- Timeout: 30 seconds
- On proxy error:
502 {"error": "bad_gateway", "message": "..."}
See API Proxying for routing details.
All admin endpoints require authentication via the requireAuth middleware.
Returns the current proxy configuration.
Response:
{
"defaultBackend": "http://backend:4070",
"allowedOrigins": ["https://app.example.com"],
"mappings": [{"frontendHost": "...", "backend": "..."}]
}Replaces the entire config.yml configuration.
Request Body: New configuration object with defaultBackend and mappings[].
Behavior:
- Validates required fields (
defaultBackend,mappingsarray) - Writes to
config.ymlon disk - Reloads the in-memory configuration
- Refreshes health checker for new/changed backends
Response: 200 {"ok": true, "config": {...}}
Reloads config.yml from disk without writing.
Response: 200 {"ok": true, "config": {...}}
Returns health status for all configured backends.
Response:
{
"http://backend:4070": {
"status": "up",
"lastUp": 1711612800000,
"firstDownAt": null,
"notifiedDown": false,
"lastCheck": 1711612800000,
"latencyMs": 45
}
}Simple liveness probe.
Response: 200 {"ok": true}
Client BFF Authentik (IdP) Redis
│ │ │ │
│ GET /auth/login │ │ │
│ ?next=/dashboard │ │ │
│───────────────────────►│ │ │
│ │ Generate PKCE pair │ │
│ │ + nonce + state │ │
│ │───────────────────────────────────────────────►│
│ │ Store state record (10m TTL) │
│ │◄───────────────────────────────────────────────│
│ 302 Redirect │ │ │
│◄───────────────────────│ │ │
│ │ │ │
│ User authenticates │ │ │
│───────────────────────────────────────────────────►│ │
│ │ │ │
│ GET /auth/callback │ │ │
│ ?code=...&state=... │ │ │
│───────────────────────►│ │ │
│ │ Retrieve + delete state │ │
│ │───────────────────────────────────────────────►│
│ │◄───────────────────────────────────────────────│
│ │ │ │
│ │ Exchange code for tokens │ │
│ │ (with code_verifier) │ │
│ │───────────────────────────►│ │
│ │ {access, refresh, id} │ │
│ │◄───────────────────────────│ │
│ │ │ │
│ │ Verify ID token (JWKS) │ │
│ │ Check: sig, iss, aud, │ │
│ │ nonce, exp, iat │ │
│ │ │ │
│ │ Create session (8h TTL) │ │
│ │───────────────────────────────────────────────►│
│ │◄───────────────────────────────────────────────│
│ 302 Redirect │ │ │
│ Set-Cookie: sid=... │ │ │
│◄───────────────────────│ │ │
Sessions are stored in Redis with the following characteristics:
| Property | Value |
|---|---|
| Key format | session:{sid} |
| TTL | 8 hours (rolling — reset on each write) |
| Session ID | 24-byte cryptographically random base64url string |
Session contents:
access_token,refresh_token,id_tokentoken_type,scopeaccess_expires_at(computed fromexpires_in)created_atuserobject (email,sub,name)
State records (used during OIDC flow):
| Property | Value |
|---|---|
| Key format | state:{state} |
| TTL | 10 minutes |
| Contents | codeVerifier, nonce, next, returnToHost, createdAt |
State records are deleted immediately after use during the callback.
The requireAuth middleware automatically refreshes tokens when the access token is within TOKEN_REFRESH_SKEW_SECONDS (default: 60) of expiry:
- On each authenticated request, check if
access_expires_atis within the skew window - If yes and a
refresh_tokenexists, POST toOIDC_TOKEN_ENDPOINTwithgrant_type=refresh_token - Update the session in Redis with the new token set
- If refresh fails, return
401 {"error": "Session expired"}(forces re-login)
When a request hits /api/*, the proxy resolves the target backend:
- Extract and normalize the
Hostheader (lowercase, split host/port) - Iterate through
config.ymlmappings:- Match
frontendHost(case-insensitive) - If
frontendPortis specified, the request port must also match - If
pathPrefixis specified,req.urlmust start with it — return this backend immediately (exact match priority) - Otherwise, save as a fallback match (host-only)
- Match
- Return: pathPrefix match > host-only match >
defaultBackend
Path rewriting: The /api prefix is stripped. For example:
/api/v1/users->/v1/users/api/graphql->/graphql
Before proxying, the BFF:
- Strips any client-provided
x-user-email,x-user-sub,x-user-nameheaders (prevents spoofing) - Injects trusted values from the authenticated session:
| Header | Source |
|---|---|
x-user-email |
ID token email claim |
x-user-sub |
ID token sub claim |
x-user-name |
ID token name claim |
Backend services can trust these headers without performing their own authentication.
WebSocket connections to wss://your-host/api/* are fully supported. The BFF handles WebSocket upgrade requests at the HTTP server level with the same authentication and header injection as regular HTTP requests.
How it works:
- The client initiates a WebSocket connection to
wss://your-host/api/some-path— the browser automatically sends the session cookie with the upgrade request - The server intercepts the
upgradeevent before it reaches Express (WebSocket upgrades bypass Express middleware) - The session cookie is parsed and validated against Redis
- If unauthenticated, the upgrade is rejected with
401 Unauthorizedand the socket is destroyed - Client-provided
x-user-*headers are stripped and trusted values from the session are injected - The
/apiprefix is stripped (same as HTTP proxy:/api/ws/chatbecomes/ws/chat) - The upgrade is forwarded to the resolved backend via the same routing algorithm as HTTP requests
Client example:
// The browser sends the session cookie automatically
const ws = new WebSocket("wss://your-bff-host/api/ws/events");
ws.onopen = () => console.log("Connected");
ws.onmessage = (e) => console.log("Message:", e.data);
ws.onclose = (e) => console.log("Closed:", e.code, e.reason);Key details:
| Property | Value |
|---|---|
| Path prefix | /api/* (same as HTTP proxy) |
| Authentication | Session cookie validated against Redis |
| User headers | x-user-email, x-user-sub, x-user-name injected |
| Path rewriting | /api prefix stripped before forwarding |
| Backend routing | Same host/port/pathPrefix algorithm as HTTP |
| Unauthenticated | Socket destroyed with 401 response |
Note: WebSocket upgrades bypass Express middleware entirely (including CORS, rate limiting, and body parsers). Authentication is handled directly in the upgrade event handler on the HTTP server. Backend services receive the same trusted x-user-* headers as they do for HTTP requests.
Security headers applied to all responses:
Cross-Origin-Opener-Policy: same-originCross-Origin-Resource-Policy: same-site- Plus all Helmet defaults: Content-Security-Policy, Strict-Transport-Security, X-Frame-Options, X-Content-Type-Options, etc.
- Origins validated against
config.ymlallowedOriginsmerged withALLOWED_ORIGINSenv variable - Credentials: enabled
- Allowed methods:
GET,POST,PUT,DELETE,PATCH,OPTIONS - Allowed headers:
Content-Type,Authorization - Requests with no
Originheader (curl, server-to-server, same-origin navigation) are allowed through
| Attribute | Value |
|---|---|
httpOnly |
true (no JavaScript access) |
secure |
true (HTTPS only) |
sameSite |
none (configurable; allows cross-site use) |
domain |
Configurable via SESSION_COOKIE_DOMAIN |
path |
/ |
A CSRF middleware (csrfCheck.js) is available that validates Origin and Referer headers against the allowed origins list for unsafe HTTP methods (POST, PUT, DELETE, PATCH). It can be mounted in the middleware stack as needed.
Auth endpoints (/auth/*) are rate-limited to 60 requests per minute per IP using express-rate-limit.
Client-provided x-user-* headers are stripped from all proxied requests before trusted values from the session are injected. This prevents clients from impersonating other users.
The health checker service periodically monitors all configured backends:
| Setting | Value |
|---|---|
| Check interval | 60 seconds |
| Method | HEAD request |
| Healthy status | 2xx–3xx response |
| Timeout | 10 seconds per check |
State tracked per backend:
status—up,down, orunknownlastUp— Timestamp of last successful checkfirstDownAt— When the current downtime startedlatencyMs— Response time (when up)notifiedDown— Whether a downtime notification has been sent
Notifications:
- Triggered when a backend has been down for 6 or more hours
- Recovery notification sent when a backend comes back up (includes downtime duration)
- Sent via Apprise notification service
Health status is viewable via GET /admin/health.
Logging uses Pino for structured, high-performance output:
| Environment | Format |
|---|---|
| Production | JSON (machine-readable) |
| Development | Pretty-printed (human-readable via pino-pretty) |
Log level is configurable via the LOG_LEVEL environment variable (default: info).
Key log points:
- Server startup and port binding
- Backend health check results (up/down/recovery)
- Configuration changes via admin API
- Authentication errors (token exchange, verification, refresh failures)
- Proxy errors (bad gateway, timeouts)
- Unhandled rejections and uncaught exceptions
- Base image:
node:20-alpine - Install:
npm ci --omit=dev(production dependencies only) - User: Runs as non-root
nodeuser - Port: 5000
- Entrypoint:
npm start
Starts two services:
Redis:
- Image:
redis:7-alpine - Persistence: AOF (Append-Only File) enabled
- Volume:
./redis_data:/data - Healthcheck:
redis-cli ping
BFF:
- Built from local Dockerfile
- Depends on Redis (waits for healthy)
- Mounts
config.ymlinto the container - Port: 5000
- Restart:
unless-stopped
# Start both services
docker-compose up
# Start in background
docker-compose up -d
# Rebuild after code changes
docker-compose up --buildTests use Vitest with the Node environment and global test functions.
Test files:
tests/requireAuth.test.js— Session validation and token refreshtests/csrfCheck.test.js— CSRF header validationtests/app.test.js— Middleware stack and 404 handlingtests/sessionStore.test.js— Redis session CRUDtests/proxyConfig.test.js— Config loading and normalizationtests/proxy.test.js— Proxy routing and header injectiontests/healthChecker.test.js— Health checking and notificationstests/admin.test.js— Admin API endpointstests/auth.test.js— OIDC auth flow
# Run tests once
npm test
# Run tests in watch mode
npm run test:watch| Script | Command | Description |
|---|---|---|
npm run dev |
node src/server.js |
Start development server |
npm start |
node src/server.js |
Start production server |
npm run lint |
eslint . |
Run ESLint |
npm test |
vitest run |
Run tests once |
npm run test:watch |
vitest |
Run tests in watch mode |
This project is licensed under the MIT License.
