React SPA + Keycloak Auth on AWS GovCloud

Summary

Recommended architecture: ALB (GovCloud) with built-in OIDC action → Nginx/S3 origin behind it → React SPA uses keycloak-js silent SSO to obtain its own access token for backend API calls.

Two factors drive this choice over the more common “CloudFront + Lambda@Edge” pattern:

  1. CloudFront is not available inside GovCloud regions and is only authorized at FedRAMP Moderate, not High. Using it with GovCloud origins is possible but requires explicit Authorizing Official sign-off as a shared-responsibility deviation. (AWS re:Post — CloudFront availability via GovCloud, cloud.gov CDN deprecation note)
  2. ALB has native OIDC authenticate-oidc action, runs entirely inside GovCloud, is FedRAMP High in scope, and supports any OIDC-compliant IdP including Keycloak. (AWS ELB docs — Authenticate users using an ALB)

A CloudFront + Lambda@Edge variant is documented at the bottom for the case where the agency permits it and global edge caching is required.

GovCloud / FedRAMP constraints (decision drivers)

ComponentAvailable in GovCloud?FedRAMP High?Notes
ALBYesYesNative OIDC authenticate-oidc listener action
S3YesYesOrigin for SPA bundle (private, accessed via VPC endpoint)
ECS Fargate / NginxYesYesLightweight static-file server in front of S3, target of ALB
CloudFrontService runs in commercial onlyModerate onlyCan point at GovCloud origins via OAC, but is outside the FedRAMP High boundary
Lambda@Edgeus-east-1 commercial onlyModerate onlySame boundary issue as CloudFront
WAF on ALB (AWS WAFv2)YesYesAdd for OWASP Top-10, rate limiting

Sources: AWS GovCloud — Setting up CloudFront, AWS FedRAMP services in scope, Lambda@Edge restrictions.

High-level architecture

flowchart LR
    User["User Browser"]
    KC[("Keycloak (other team)")]

    subgraph GC["AWS GovCloud VPC"]
        WAF["AWS WAF"]
        ALB["ALB (OIDC action enabled)"]
        FE["ECS Fargate -- Nginx serving SPA"]
        S3[("S3 SPA bundle (private)")]
        API["ECS Fargate -- Backend API"]
    end

    User -->|HTTPS| WAF --> ALB
    ALB -->|unauthenticated| KC
    ALB -->|authenticated default rule| FE
    FE -->|read bundle| S3
    User -->|XHR with Bearer JWT| WAF
    ALB -->|api rule| API
    API -->|JWKS validate| KC

Notes:

  • ALB has two listener rules on the same HTTPS listener:
    • /api/* → backend target group, no authenticate-oidc action (the SPA attaches its own bearer token; backend validates the JWT against Keycloak’s JWKS).
    • default /* → frontend target group, authenticate-oidc action first. Unauthenticated requests get 302’d to Keycloak.
  • ALB stores its OIDC session in an AWSELBAuthSessionCookie-* cookie (HttpOnly, Secure, encrypted).
  • Backend validates JWTs out-of-band via Keycloak’s JWKS endpoint (cached); ALB does not pass tokens to the API target group on the API rule because that rule has no auth action.
  • WAFv2 enforces OWASP Top 10, IP reputation, rate limiting in front of the ALB.

Why not just rely on the ALB session for API calls?

ALB’s OIDC integration places the access token and claims into request headers (x-amzn-oidc-accesstoken, x-amzn-oidc-data) only on rules that have the authenticate-oidc action. It does not forward the ID token, and the access token rotates with the ALB session, not the Keycloak refresh-token lifecycle. (AWS docs)

For a proper SPA → API model where the backend treats the user’s Keycloak access token as the credential, we want the SPA to hold (in memory) a Keycloak-issued access token that it attaches as Authorization: Bearer <jwt>. This keeps the API stateless and lets it federate with non-browser clients later.

Sequence — first load (unauthenticated)

sequenceDiagram
    autonumber
    participant U as Browser
    participant ALB as ALB (GovCloud)
    participant KC as Keycloak
    participant FE as Nginx/S3 (SPA bundle)

    U->>ALB: GET / -- no session cookie
    ALB-->>U: 302 to Keycloak authorize endpoint
    U->>KC: Follow redirect and present credentials
    KC-->>U: 302 back to /oauth2/idpresponse with code
    U->>ALB: GET /oauth2/idpresponse with code
    ALB->>KC: Back-channel POST to token endpoint
    KC-->>ALB: id_token, access_token, refresh_token
    ALB-->>U: Set AWSELBAuthSessionCookie and 302 to /
    U->>ALB: GET / with session cookie
    ALB->>FE: Forward request and add x-amzn-oidc headers
    FE-->>U: index.html plus JS and CSS bundle

Once index.html is in the browser, the SPA boots and immediately initializes Keycloak (next diagram).

Sequence — SPA obtains its own access token (silent SSO)

The user already has a Keycloak SSO session from the ALB redirect, so the in-app login is invisible.

sequenceDiagram
    autonumber
    participant SPA as React SPA (keycloak-js)
    participant Hidden as Hidden iframe
    participant KC as Keycloak
    participant API as Backend API

    Note over SPA: keycloak.init with onLoad check-sso,<br/>silentCheckSsoRedirectUri set,<br/>pkceMethod S256
    SPA->>Hidden: Load silent-check-sso.html in iframe
    Hidden->>KC: GET authorize with prompt=none and PKCE challenge
    KC-->>Hidden: 302 with code (existing SSO session)
    Hidden-->>SPA: postMessage code to parent
    SPA->>KC: POST token endpoint with code and verifier (PKCE, public client)
    KC-->>SPA: access_token, id_token, refresh_token
    Note over SPA: Store access_token in memory only
    SPA->>API: GET /api/things with Bearer access_token
    API->>KC: Fetch JWKS (cached)
    Note over API: Verify signature, iss, aud, exp
    API-->>SPA: 200 OK with payload

Library choice: keycloak-js (official, well-supported with Keycloak server) or oidc-spa (newer, opinionated, handles tab-sync / silent renew quirks more thoroughly). Both implement Authorization Code + PKCE with S256. Avoid Implicit flow — it is deprecated by IETF OAuth 2.0 for Browser-Based Apps. Sources: keycloak-js adapter, oidc-spa.

Keycloak client config for the SPA:

  • Client type: OpenID Connect
  • Client authentication: Off (public client)
  • Standard flow: On, Implicit flow: Off, Direct access grants: Off
  • Proof Key for Code Exchange: S256
  • Valid redirect URIs and Web origins restricted to the app’s domain

Sequence — silent token refresh

sequenceDiagram
    autonumber
    participant SPA as React SPA
    participant KC as Keycloak

    Note over SPA: keycloak.updateToken(60) on a timer,<br/>or before any API call expiring within 60s
    SPA->>KC: POST token endpoint with refresh_token grant
    KC-->>SPA: New access_token (and rotated refresh_token if enabled)
    Note over SPA: Replace tokens in memory

Keycloak supports refresh-token rotation (recommended for SPA public clients). Combined with short access-token TTLs (e.g. 5–15 min) this gives reasonable defense in depth even though tokens live in JS memory. (Curity SPA best practices, Ping — RT rotation in SPAs)

Sequence — logout

sequenceDiagram
    autonumber
    participant U as Browser
    participant SPA as React SPA
    participant ALB as ALB
    participant KC as Keycloak

    U->>SPA: Click sign out
    SPA-->>U: Redirect to ALB /logout
    U->>ALB: GET /logout (unauthenticated rule)
    ALB-->>U: Fixed response that expires AWSELBAuthSessionCookie,<br/>then 302 to Keycloak end_session_endpoint with id_token_hint
    U->>KC: GET end_session_endpoint
    Note over KC: End SSO session
    KC-->>U: 302 to post_logout_redirect_uri
    U->>ALB: GET / -- no session cookie now
    ALB-->>U: 302 to Keycloak authorize (fresh login required)

Gotcha documented by Keycloak community: ALB does not surface a logout endpoint of its own, so a full sign-out requires both (a) clearing the AWSELBAuthSessionCookie-* cookies and (b) Keycloak RP-initiated logout. Implement an unauthenticated /logout listener rule that returns a fixed response with Set-Cookie headers expiring the ALB cookies plus a 302 to Keycloak’s end_session_endpoint with id_token_hint. The SPA must therefore stash the id_token (or pass it on the redirect URL) so this works. (Keycloak GH discussion #15884)

Token storage decision

OptionVerdictReason
localStorage / sessionStorageReadable by any XSS, persists across reloads
In-memory variable (closure)✅ for access tokenStandard recommendation in IETF OAuth 2.0 for Browser-Based Apps
HttpOnly Secure cookie✅ for refresh token (if BFF)Only safe if a BFF is the OAuth client
BFF holds all tokens✅✅ strongestRecommended by OWASP OAuth2 Cheat Sheet and Curity

For this design we chose in-memory access token + Keycloak refresh-token rotation + short TTLs, on the basis that the SPA is the OAuth client. If the threat model demands stronger XSS containment, evolve to a true BFF: replace direct keycloak-js token calls with a backend-for-frontend service that holds tokens server-side and exposes a same-origin cookie-authenticated session to the SPA. This is a bigger lift and adds an always-on backend on the auth path.

Variant — CloudFront + Lambda@Edge (only if AO approves)

If the agency Authorizing Official accepts CloudFront in the system boundary (as a FedRAMP Moderate component fronting a FedRAMP High origin) and you need true global edge caching:

flowchart LR
    User --> CF["CloudFront (commercial)"]
    CF -->|viewer-request| LE["Lambda@Edge (us-east-1)"]
    LE -->|valid JWT cookie| CF
    LE -->|missing or expired| KC[(Keycloak)]
    CF -->|OAC| S3[("S3 GovCloud SPA bundle")]

The Lambda@Edge function on viewer-request:

  • inspects a JWT cookie set by an earlier /callback
  • if missing/expired, 302s to Keycloak /auth with state
  • on /callback, exchanges code for tokens, sets HttpOnly Secure cookie, redirects to original URL
  • otherwise validates JWT signature against Keycloak’s published JWKS (JWKS fetched at cold-start; cached)

Reference implementations: aws-samples/cloudfront-authorization-at-edge (Cognito; adaptable), aws-samples/lambdaedge-openidconnect-samples, Bernhard Thüsch — Authorization@Edge with Keycloak, AWS blog — Securing CloudFront with OIDC.

Lambda@Edge constraints to plan around: 5 s viewer-request timeout, 128 MB max memory, 1 MB compressed bundle, no env vars (use Secrets Manager + replicated secret), publish must be in us-east-1. (AWS Lambda@Edge restrictions)

Open questions to resolve with the Keycloak team

  1. Realm and client setup: who owns realm config? We need a public PKCE client for the SPA and a confidential client for the ALB OIDC action (ALB requires client_secret).
  2. Refresh-token rotation: enabled? Rotation interval and reuse-detection policy.
  3. Token TTLs: agree on access token (5–15 min) and SSO session idle/max.
  4. CORS / Web Origins whitelisting in the SPA client to allow XHR from app domain to Keycloak /token.
  5. JWKS caching SLA: how often does Keycloak rotate signing keys, and what is the JWKS endpoint URL we should hard-code into backend config?
  6. Logout: is RP-initiated logout (end_session_endpoint) enabled? Back-channel logout supported?
  7. mTLS or network path between GovCloud VPC and Keycloak — VPC peering, PrivateLink, or public internet over TLS?

Sources