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:
- 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)
- ALB has native OIDC
authenticate-oidcaction, 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)
| Component | Available in GovCloud? | FedRAMP High? | Notes |
|---|---|---|---|
| ALB | Yes | Yes | Native OIDC authenticate-oidc listener action |
| S3 | Yes | Yes | Origin for SPA bundle (private, accessed via VPC endpoint) |
| ECS Fargate / Nginx | Yes | Yes | Lightweight static-file server in front of S3, target of ALB |
| CloudFront | Service runs in commercial only | Moderate only | Can point at GovCloud origins via OAC, but is outside the FedRAMP High boundary |
| Lambda@Edge | us-east-1 commercial only | Moderate only | Same boundary issue as CloudFront |
| WAF on ALB (AWS WAFv2) | Yes | Yes | Add 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, noauthenticate-oidcaction (the SPA attaches its own bearer token; backend validates the JWT against Keycloak’s JWKS).- default
/*→ frontend target group,authenticate-oidcaction 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 ConnectClient authentication: Off(public client)Standard flow: On,Implicit flow: Off,Direct access grants: OffProof 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
| Option | Verdict | Reason |
|---|---|---|
localStorage / sessionStorage | ❌ | Readable by any XSS, persists across reloads |
| In-memory variable (closure) | ✅ for access token | Standard 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 | ✅✅ strongest | Recommended 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
/authwith state - on
/callback, exchanges code for tokens, sets HttpOnlySecurecookie, 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
- 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). - Refresh-token rotation: enabled? Rotation interval and reuse-detection policy.
- Token TTLs: agree on access token (5–15 min) and SSO session idle/max.
- CORS /
Web Originswhitelisting in the SPA client to allow XHR from app domain to Keycloak/token. - JWKS caching SLA: how often does Keycloak rotate signing keys, and what is the JWKS endpoint URL we should hard-code into backend config?
- Logout: is RP-initiated logout (
end_session_endpoint) enabled? Back-channel logout supported? - mTLS or network path between GovCloud VPC and Keycloak — VPC peering, PrivateLink, or public internet over TLS?
Sources
- AWS re:Post — CloudFront availability via GovCloud
- AWS GovCloud docs — Setting up CloudFront with GovCloud resources
- AWS — FedRAMP services in scope
- AWS ELB docs — Authenticate users using an Application Load Balancer
- Quick steps to authenticate users with AWS ALB and Keycloak
- Keycloak — JavaScript adapter (
keycloak-js) - Keycloak — Securing applications with OIDC
- oidc-spa
- IETF — OAuth 2.0 for Browser-Based Applications
- OWASP — OAuth2 Cheat Sheet
- Curity — Best practices for OAuth in SPAs
- Ping Identity — Refresh token rotation in SPAs
- Duende — BFF pattern for SPAs
- AWS Networking Blog — Securing CloudFront with OIDC + Secrets Manager
- AWS Networking Blog — Authorization@Edge with Lambda@Edge and JWTs
- aws-samples/cloudfront-authorization-at-edge
- aws-samples/lambdaedge-openidconnect-samples
- Bernhard Thüsch — Authorization@Edge with Keycloak
- Keycloak GH discussion #15884 — Logout with AWS ALB
- Lambda@Edge restrictions