Implementing Stateless Authentication Flows for SPAs
Part of the Statelessness & Caching Strategies topic under API Design Fundamentals & Architecture.
A stateless authentication flow means the server holds no session record. Every request carries a self-describing credential — typically a signed JWT — and the gateway validates it in isolation. For a single-page application this creates a specific tension: the SPA must obtain, store, refresh, and inject that token without a backend session to fall back on, while the OpenAPI contract must make the security requirement explicit so generated clients and CI pipelines stay aligned.
When this scenario occurs
This page applies when you see any of the following:
| Trigger | Signal |
|---|---|
401 Unauthorized on first page load, clears after hard refresh |
Stale token or hydration race before refresh completes |
401 on all endpoints simultaneously after a period of inactivity |
Access token expired; refresh not triggered |
403 Forbidden despite a valid-looking token |
aud or iss claim mismatch between OIDC issuer and gateway config |
Generated SDK sends Authorization: Bearer undefined |
Codegen consumed a stale securitySchemes snapshot |
Clock-skew exp failures in staging but not locally |
NTP drift between OIDC issuer and API gateway hosts |
OpenAPI 3.1 spec snippet
Declare the BearerAuth security scheme in your spec and apply it globally. This is the contract that generated clients, gateway validators, and Spectral linting rules all read from.
# openapi.yaml — OpenAPI 3.1.0
openapi: "3.1.0"
info:
title: My API
version: "1.0.0"
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
# x- extension: document required claims for gateway validators
x-jwt-claims:
required:
- exp # expiry — gateway rejects tokens past this Unix timestamp
- iss # issuer — must match the gateway's trusted issuer URL
- aud # audience — must match the target service identifier
- sub # subject — user or service identity
security:
- BearerAuth: [] # applied globally; override per-path if needed
paths:
/profile:
get:
summary: Get authenticated user profile
responses:
"200":
description: Profile data
"401":
description: Token missing, expired, or signature invalid
"403":
description: Token valid but claims do not authorise this resource
The x-jwt-claims extension is not part of the OpenAPI specification but is widely supported as a gateway hint (AWS API Gateway, Kong, Traefik). It keeps claim requirements co-located with the security scheme rather than buried in gateway config files.
Step-by-step implementation
The diagram below shows the complete token lifecycle for a SPA: initial login, silent refresh, and the intercept layer that serialises concurrent requests during a refresh.
Step 1 — Declare and apply the security scheme
The spec snippet above is the authoritative source. Validate it before any code:
# Install once
npm install --save-dev @stoplight/spectral-cli
# Lint security scheme coverage across all paths
npx spectral lint openapi.yaml --ruleset .spectral.yaml
Step 2 — Issue short-lived access tokens and build a refresh queue
Configure the OIDC provider to issue access tokens with a 5–15 minute exp. On the client, gate all token access through a single async function that holds one refresh Promise at a time. Concurrent callers all await the same promise rather than each triggering their own refresh.
TypeScript:
// src/auth/token-queue.ts
let refreshPromise: Promise<string> | null = null;
let currentToken: string = '';
let tokenExpiry: number = 0;
function isExpired(): boolean {
// Subtract 30 s to refresh before hard expiry
return Date.now() / 1000 >= tokenExpiry - 30;
}
export async function getValidToken(): Promise<string> {
if (!isExpired()) return currentToken;
if (!refreshPromise) {
refreshPromise = fetch('/oauth2/token', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'grant_type=refresh_token',
})
.then((res) => {
if (!res.ok) throw new Error('Token refresh failed');
return res.json();
})
.then((data: { access_token: string; expires_in: number }) => {
currentToken = data.access_token;
tokenExpiry = Date.now() / 1000 + data.expires_in;
refreshPromise = null;
return currentToken;
})
.catch((err) => {
refreshPromise = null;
throw err;
});
}
return refreshPromise;
}
Python (equivalent, for a BFF or CLI client):
# auth/token_queue.py
import asyncio, time, httpx
_refresh_task: asyncio.Task | None = None
_current_token: str = ""
_token_expiry: float = 0.0
def _is_expired() -> bool:
return time.time() >= _token_expiry - 30
async def get_valid_token(client: httpx.AsyncClient) -> str:
global _refresh_task, _current_token, _token_expiry
if not _is_expired():
return _current_token
if _refresh_task is None or _refresh_task.done():
async def _refresh() -> str:
global _current_token, _token_expiry
r = await client.post(
"/oauth2/token",
data={"grant_type": "refresh_token"},
)
r.raise_for_status()
body = r.json()
_current_token = body["access_token"]
_token_expiry = time.time() + body["expires_in"]
return _current_token
_refresh_task = asyncio.create_task(_refresh())
return await _refresh_task
Step 3 — Inject the token via an HTTP interceptor
Wrapping the generated Axios client with a response interceptor keeps token injection out of application code and handles the single-retry pattern for expired tokens.
// src/api/client.ts
import axios from 'axios';
import { getValidToken } from '../auth/token-queue';
export const apiClient = axios.create({ baseURL: '/api/v1' });
// Inject token on every outgoing request
apiClient.interceptors.request.use(async (config) => {
config.headers.Authorization = `Bearer ${await getValidToken()}`;
return config;
});
// Retry once on 401 (handles edge-case where token expired between request start and gateway check)
apiClient.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config;
if (error.response?.status === 401 && !original._retried) {
original._retried = true;
// Force a fresh token (isExpired is now true after a real 401)
original.headers.Authorization = `Bearer ${await getValidToken()}`;
return apiClient(original);
}
return Promise.reject(error);
},
);
Step 4 — Validate JWT claims on the server / gateway
Gateway-side validation must check four claim groups in order. The gateway should return structured RFC 7807 Problem JSON errors so the SPA can distinguish recoverable (expired) from terminal (audience mismatch) failures.
// middleware/jwt-auth.ts (Express + jose)
import { jwtVerify, createRemoteJWKSet } from 'jose';
import type { Request, Response, NextFunction } from 'express';
const JWKS = createRemoteJWKSet(new URL(process.env.OIDC_JWKS_URI!));
export async function requireBearer(req: Request, res: Response, next: NextFunction) {
const auth = req.headers.authorization ?? '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : null;
if (!token) {
return res.status(401).json({
type: '/errors/missing-credentials',
title: 'Authorization header required',
status: 401,
});
}
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: process.env.OIDC_ISSUER, // validates iss
audience: process.env.API_AUDIENCE, // validates aud
clockTolerance: 30, // seconds of clock-skew tolerance
});
res.locals.userId = payload.sub;
next();
} catch (err: any) {
const expired = err.code === 'ERR_JWT_EXPIRED';
return res.status(401).json({
type: expired ? '/errors/token-expired' : '/errors/token-invalid',
title: expired ? 'Access token has expired' : 'Access token is invalid',
status: 401,
});
}
}
Step 5 — CI contract guards
Add a Spectral rule that rejects any path in your spec that is missing a security requirement. Pair this with a mock-OIDC test that exercises the full refresh cycle.
# .spectral.yaml
rules:
bearer-auth-required:
message: "All non-public endpoints must declare BearerAuth security"
severity: error
given: "$.paths.*[get,post,put,patch,delete]"
then:
field: security
function: truthy
jwt-claims-documented:
message: "BearerAuth securityScheme must document required JWT claims via x-jwt-claims"
severity: warn
given: "$.components.securitySchemes.BearerAuth"
then:
field: x-jwt-claims.required
function: truthy
# .github/workflows/auth-contract.yml
name: Auth contract check
on: [pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint OpenAPI security requirements
run: |
npm ci
npx spectral lint openapi.yaml --ruleset .spectral.yaml --fail-severity error
- name: Start mock OIDC server
run: docker run -d -p 9090:9090 ghcr.io/navikt/mock-oauth2-server:latest
- name: Run auth contract tests
run: npx jest --testPathPattern="auth-contract"
RFC and standard compliance
| Requirement | Source | Detail |
|---|---|---|
exp, iss, aud, sub claim validation |
RFC 7519 §4.1 | Mandatory for JWTs used as bearer tokens |
Bearer token scheme |
RFC 6750 §2.1 | Authorization: Bearer <token> header format |
| Clock skew tolerance ≤ 300 s | RFC 7519 §4.1.4 | Most gateways default to 30–60 s; keep it low |
| Refresh token rotation | RFC 6749 §10.4 | Issue a new refresh token on each use; invalidate the old one |
no-store on authenticated responses |
RFC 9111 §5.2 | Prevents authenticated data entering shared caches |
401 with WWW-Authenticate on missing/invalid token |
RFC 9110 §15.5.2 | Gateway must include the challenge header |
Idempotency and caching implications
Stateless authentication interacts directly with caching. An API response tied to a specific user identity must carry Cache-Control: no-store or Cache-Control: private to prevent a shared CDN layer from serving one user’s data to another. Additionally, write operations made within a stateless flow should pair the Authorization header with an Idempotency Key Implementation header so that a token refresh followed by an automatic request retry cannot duplicate a mutation. The server must treat the re-sent request — same idempotency key — as a no-op and return the original response rather than applying the write twice.
SDK and codegen downstream effect
The way securitySchemes is declared in the spec directly controls what generated clients emit. The diff below shows the before (static placeholder) and after (interceptor-injected) state for an Axios-based TypeScript SDK.
// BEFORE: openapi-generator default output
export class ProfileApi {
async getProfile(): Promise<Profile> {
- const headers = { Authorization: 'Bearer YOUR_API_KEY' }; // static placeholder
return this.client.get('/profile', { headers });
}
}
// AFTER: generator configured with --auth-type=none, interceptor injected externally
export class ProfileApi {
+ // No auth logic in generated code; injected by apiClient interceptor
async getProfile(): Promise<Profile> {
return this.client.get('/profile');
}
}
Configure openapi-generator-cli with --auth=none (or the equivalent withSeparateModelsAndApi flag in the TypeScript-Axios generator) to prevent static auth headers from being baked into generated files. The interceptor from Step 3 covers all generated endpoints centrally.
openapi-generator-cli generate \
-i openapi.yaml \
-g typescript-axios \
--additional-properties=withSeparateModelsAndApi=true,apiPackage=api,modelPackage=models \
--auth=none
CI should lint for hardcoded Bearer strings in generated output:
# scripts/lint-generated-auth.sh
if grep -rn "Bearer YOUR_API_KEY\|Authorization.*static" generated-client/src/; then
echo "FAIL: Hardcoded auth token found in generated client"
exit 1
fi
echo "PASS: No hardcoded auth tokens in generated client"
Common mistakes
| Mistake | Correct approach |
|---|---|
Storing access token in localStorage |
Keep it in memory; store only the refresh token in httpOnly; Secure; SameSite=Strict cookie |
Triggering a new refresh from every parallel 401 |
Use a shared Promise so all callers await one refresh, not N |
Trusting client-parsed exp for validation |
Gateway must validate exp cryptographically; client parsing is for UX only (pre-expire warnings) |
Setting clockTolerance > 300 s |
Keep tolerance at 30–60 s; wider tolerance allows replay attacks against expired tokens |
Omitting aud validation on the gateway |
An OIDC token valid for service A must be rejected by service B; always validate aud |
Returning 500 when a JWT is expired |
Return 401 with a WWW-Authenticate: Bearer error="invalid_token" header so the client knows to refresh |
FAQ
How do I prevent cascading 401 errors when multiple SPA requests fire before token refresh completes?
Serialise refresh with a shared Promise. The first caller to detect an expired token initiates the refresh and stores the promise on a module-level variable. All subsequent callers that check the same variable await that same promise rather than each firing their own POST /oauth2/token request. Once the refresh resolves, all callers get the new token and retry.
Why does my OpenAPI-generated client send Authorization: Bearer undefined?
Generated clients read securitySchemes at build time and often capture the scheme name as a string placeholder. Regenerate with --auth=none to strip static headers from generated code, then add a request interceptor (see Step 3) that injects the live token at call time. Never use defaultHeaders in the generated configuration object.
Can I use httpOnly cookies instead of Authorization headers for SPA auth?
Yes, and it removes the need for JavaScript to ever touch the token. Set SameSite=Strict; Secure; HttpOnly on the session cookie, remove the BearerAuth security scheme from your spec, and have the gateway read the cookie directly. CORS must then allow credentials (Access-Control-Allow-Credentials: true) and cannot use a wildcard origin. This trades the silent-refresh complexity for CSRF protection requirements — appropriate when the SPA and API share the same origin.
Related
- Statelessness & Caching Strategies — parent topic covering
Cache-Controldesign, ETag patterns, and the full statelessness contract - API Design Fundamentals & Architecture — pillar covering resource modeling, HTTP method safety, and idempotency foundations
- Idempotency Key Implementation — how to guard non-idempotent writes behind a deduplication key, essential when auth retries can replay mutations
- RFC 7807 Problem JSON Implementation — standardising
401/403error response bodies across microservices - Retryable vs Non-Retryable Errors — classifying
401 token-expired(retryable after refresh) vs403 forbidden(non-retryable)