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.

SPA stateless authentication flow Sequence diagram showing SPA login via OIDC, JWT storage in memory, API requests with Bearer header injection, silent refresh triggered on token expiry, and the shared-Promise refresh queue that prevents duplicate refresh calls. SPA (Browser) Auth Interceptor OIDC Provider API Gateway 1. Authorization Code + PKCE 2. access_token + refresh_token 3. Store in memory only 4. fetch('/api/profile') 5. Authorization: Bearer <token> 6. Validate exp / iss / aud / sig 7. 200 OK (or 401 if expired) 8. grant_type=refresh_token 9. new access_token 10. Retry with new Bearer token 11. 200 OK — response delivered to SPA

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.