GovCloud-Compatible React SPA Architecture
GovCloud-Compatible React SPA Architecture
Problem framing
- Static React SPA + small backend (AppConfig feature flags, OTel collector, legal-document endpoints).
- Auth team owns the Cognito user pool. Only authenticated users may fetch frontend assets or call any backend.
- Backend team owns separate APIs that the SPA calls directly with the Cognito access token.
- Must be GovCloud compatible.
GovCloud constraints that shape the design
These constraints rule out the typical commercial-AWS pattern (CloudFront + Lambda@Edge for auth):
| Service | GovCloud status | Impact |
|---|---|---|
| CloudFront | Not available in GovCloud; only usable from standard regions pointing at GovCloud origins (cross-partition, treated as public internet) | No native CDN in front of GovCloud workloads |
| Lambda@Edge / CloudFront Functions | Authored only in us-east-1, edge runs only on commercial CloudFront | Can’t do edge auth on a GovCloud-resident architecture |
| Cognito User Pools | Available, FIPS endpoints (auth-fips.us-gov-west-1.amazoncognito.com) | Custom domains not supported; new “Managed Login” tiers not available — classic Hosted UI only |
ALB authenticate-cognito action | Supported | Becomes the natural auth perimeter |
| API Gateway | Available, regional only (no edge-optimized); FIPS by default | Fine for backend; can’t replace a CDN |
| AppConfig | Available (CodePipeline integration unavailable in us-gov-east-1) | Use directly |
Sources are listed at the bottom.
High-level architecture
flowchart LR
user([Authenticated User])
subgraph govcloud["AWS GovCloud (us-gov-west-1)"]
subgraph perimeter["Public perimeter"]
r53[Route 53\nrecord]
acm[ACM cert]
alb[Application Load Balancer\nHTTPS listener\nauthenticate-cognito action]
end
subgraph compute["Private subnets"]
tg_static[Target group:\nFargate nginx serving SPA]
tg_api[Target group:\nLambda for our backend]
end
s3[(S3 bucket\nSPA build artifacts)]
ecr[(ECR\nnginx+SPA image)]
appconfig[AWS AppConfig\nfeature flags]
otel[OTel collector\n on Fargate or ADOT Lambda]
secrets[Secrets Manager\n / SSM Parameter Store]
end
subgraph auth["Auth team account"]
cognito[Cognito User Pool\nFIPS Hosted UI]
end
subgraph backend["Backend team account"]
bteam_api[Backend APIs\nAPI Gateway + Cognito Authorizer]
end
user -- 1. HTTPS --> r53 --> alb
alb -- 2. unauth -> redirect --> cognito
cognito -- 3. code --> alb
alb -- 4. token exchange --> cognito
alb -- 5. / --> tg_static
alb -- 5. /api/* --> tg_api
tg_static --- ecr
tg_api --> appconfig
tg_api --> otel
tg_api --> secrets
user -- 6. direct call w/ access token --> bteam_api
bteam_api -- validates JWKS --> cognito
Why ALB-centric, not CloudFront-centric
- ALB’s built-in
authenticate-cognitolistener action is the cheapest way to enforce “only authenticated users can fetch frontend assets” in GovCloud. It handles the OIDC dance, cookie session, and forwards verified user identity to targets viaX-Amzn-Oidc-*headers. - A single ALB protects both the SPA and the in-app APIs with one auth boundary; no per-route authorizer code.
- If a CDN is later required, you can layer commercial-region CloudFront in front of the GovCloud ALB as a custom origin (Origin Access Control does not work cross-partition; secure with a shared secret header + WAF rule).
Why Fargate+nginx for the SPA, not Lambda
- ALB → Lambda target group has a 1 MB synchronous response limit. Even a moderate SPA bundle (hashed JS chunks, source maps, fonts) blows past this.
- Fargate task running nginx with the SPA build baked into the image is simple, scales to zero traffic at minimum task count, and keeps the static path completely free of cold-start risk.
- Build pipeline:
vite build→ copydist/into nginx container → push to ECR → ECS service rolling deploy.
Alternative if you want fully serverless static hosting: keep the SPA in S3 and put a tiny Lambda or a small custom origin proxy on Fargate that streams from S3. Not worth the complexity for a small app.
Cognito integration details (GovCloud-specific)
- The auth team’s user pool lives in
us-gov-west-1(orus-gov-east-1). You register a new app client for this SPA. - App client config:
- Allowed callback URL:
https://app.example.gov/oauth2/idpresponse(the ALB-managed callback path). - Allowed sign-out URL:
https://app.example.gov/. - OAuth flows: Authorization code grant. Do not use implicit flow.
- Scopes:
openid, plus any custom scopes the backend team defines for their APIs. - Client secret: required for
authenticate-cognitoALB action; store in Secrets Manager and reference via the ALB action config.
- Allowed callback URL:
- Hosted UI: use the FIPS endpoint (
*.auth-fips.us-gov-west-1.amazoncognito.com). Custom domains aren’t supported in GovCloud, so plan branding around the prefix-domain Hosted UI or build a custom login UI hosted on your ALB and call Cognito’s API endpoints directly. - The ALB sets an
AWSELBAuthSessionCookie-*cookie after the dance. It also forwards three headers to targets:X-Amzn-Oidc-Data(signed JWT of the claims),X-Amzn-Oidc-AccessToken,X-Amzn-Oidc-Identity.
Request flows
Flow 1 — Unauthenticated user visits the app
sequenceDiagram
autonumber
participant U as Browser
participant ALB
participant C as Cognito Hosted UI
U->>ALB: GET https://app.example.gov/
ALB->>ALB: No AWSELB session cookie
ALB-->>U: 302 to Cognito /authorize?client_id=...&redirect_uri=.../oauth2/idpresponse
U->>C: GET /authorize
C-->>U: Hosted UI login page
U->>C: POST credentials (and MFA)
C-->>U: 302 back to /oauth2/idpresponse?code=AUTH_CODE
U->>ALB: GET /oauth2/idpresponse?code=AUTH_CODE
ALB->>C: POST /oauth2/token (code + client_secret)
C-->>ALB: id_token, access_token, refresh_token
ALB->>ALB: Set AWSELBAuthSessionCookie-*; cache tokens server-side
ALB-->>U: 302 to original URL "/"
U->>ALB: GET / (now with session cookie)
ALB->>ALB: Validate cookie, forward
ALB->>Fargate: GET / + X-Amzn-Oidc-* headers
Fargate-->>U: index.html (SPA shell)
Key point: ALB does all the OIDC plumbing. The SPA never sees the auth code or client secret.
Flow 2 — Authenticated SPA load (warm session)
- Browser sends request with
AWSELBAuthSessionCookie-0/-1cookies. - ALB validates session, refreshes underlying tokens if near expiry.
- ALB forwards to Fargate static target group.
- nginx returns hashed asset (
/assets/index-abc123.js) with a longCache-Control: public, max-age=31536000, immutable. - SPA boots in browser. To get the access token for downstream API calls, see Flow 4.
Flow 3 — SPA calls our backend API (/api/feature-flags, /api/legal/tos, /api/otel/v1/traces)
sequenceDiagram
autonumber
participant SPA
participant ALB
participant L as Lambda (api target)
participant AC as AppConfig
SPA->>ALB: GET /api/feature-flags (cookie + Authorization: Bearer <access_token>)
ALB->>ALB: Validate AWSELB session cookie
ALB->>L: forward + X-Amzn-Oidc-Data (signed JWT)
L->>L: Verify X-Amzn-Oidc-Data signature against ALB public key (FIPS S3 endpoint)
L->>AC: GetLatestConfiguration
AC-->>L: config blob
L-->>SPA: 200 { flags: { ... } }
- The Lambda must verify
X-Amzn-Oidc-Dataitself; ALB only attests the session at the listener, but a target receiving the header without verification could be spoofed if anything else can reach the target group. Public keys come fromhttps://s3-us-gov-west-1.amazonaws.com/aws-elb-public-keys-prod-us-gov-west-1/<key-id>. - AppConfig: Lambda extension (
AWS-AppConfig-Extension-Arm64) caches the config and polls. Keep TTL ≥ 30s to avoid GetLatestConfiguration throttling. - OTel ingest: prefer running the OTel collector as a sidecar on Fargate or as an ADOT Lambda layer co-located with the API target — don’t make it a public OTLP endpoint. The
/api/otel/*route is just a thin authenticated forwarder.
Flow 4 — SPA calls the backend team’s APIs
sequenceDiagram
autonumber
participant SPA
participant ALB
participant API as /api/auth/token
participant BT as Backend Team API
participant C as Cognito
Note over SPA: SPA needs the Cognito access token<br/>but it's only in the ALB session, not the browser
SPA->>ALB: GET /api/auth/token (cookie)
ALB->>API: forward + X-Amzn-Oidc-AccessToken
API-->>SPA: { access_token, expires_in }
SPA->>BT: GET /v1/things\nAuthorization: Bearer <access_token>
BT->>BT: Cognito Authorizer / JWT validation against Cognito JWKS
BT-->>SPA: 200 payload
This is the central design choice: the SPA never holds a refresh token. The browser-side access token is fetched on demand from a same-origin endpoint that reads it out of the ALB-injected X-Amzn-Oidc-AccessToken header.
Why this is better than the obvious alternative (SPA does its own PKCE flow):
- One auth perimeter. The ALB is the only place that holds refresh tokens.
- No tokens in localStorage / no XSS exfil risk.
- Logout works by clearing the ALB session cookie + Cognito sign-out URL — no token revocation choreography in the SPA.
Cost: the SPA needs to call /api/auth/token whenever its in-memory token is expired. Implement as an interceptor in the API client.
Flow 5 — Token expiry and refresh
- ALB caches tokens in its session and silently refreshes them using the Cognito refresh token before forwarding requests, as long as the session cookie is valid (default 7 days, configurable on the listener rule via
SessionTimeout). - When the ALB session expires, the next request 302s back to Cognito; if the user still has a valid Cognito session, it’s a transparent re-auth. Otherwise they see the Hosted UI again.
- The SPA should treat 401 from the backend team’s APIs as “fetch a fresh token from
/api/auth/tokenand retry once.” If that also 401s, dowindow.location = '/'to trigger the ALB → Cognito redirect.
Flow 6 — Logout
- SPA links to
https://app.example.gov/logout(a route owned by the API Lambda). - Lambda clears the ALB session cookie (
Set-Cookie: AWSELBAuthSessionCookie-0=; Max-Age=0; Domain=...; Secure; HttpOnly). - Returns 302 to Cognito
/logout?client_id=...&logout_uri=https://app.example.gov/. - Cognito clears its session, redirects back to the app root, ALB sees no cookie → starts Flow 1.
Authorization model
- Coarse-grained “is this person allowed in the app?” → enforced by ALB authenticate-cognito + Cognito group membership claim in the ID token. Use ALB listener rules on
X-Amzn-Oidc-Dataclaims (or check in the Lambda) to gate/admin/*paths. - Fine-grained “can this user call this backend-team endpoint?” → backend team’s responsibility, enforced in their Cognito Authorizer using scopes and groups.
- Don’t try to mirror the backend team’s authorization in the SPA. SPA just shows/hides UI as a UX hint; the backend rejects unauthorized requests.
CDK component sketch
Stack: NetworkStack
Vpc (2 AZs, public + private subnets)
Stack: AuthStack
CognitoAppClient (added to auth team's pool via cross-account custom resource)
Secret (client secret -> SSM/Secrets Manager)
Stack: FrontendStack
EcrRepository (nginx-spa)
FargateService (1 task min, autoscale on ALBRequestCountPerTarget)
TargetGroup (port 80, healthcheck /healthz)
Stack: ApiStack
Function (apiHandler, ARM64, AppConfig extension layer)
TargetGroup (Lambda target)
AppConfigApp + Env + Profile
OtelCollectorService (Fargate sidecar OR ADOT layer)
Stack: EdgeStack
ApplicationLoadBalancer (HTTPS only, redirect 80->443)
Listener
.addAction(authenticateCognito({ userPool, userPoolClient, sessionTimeout: 12h })
.next(forward(staticTg)))
.addAction(default rule for /api/* -> apiTg, also wrapped in authenticate-cognito)
ARecord (Route53)
Acm (DNS-validated cert)
Open questions / risks
- Cognito custom domain: not supported in GovCloud. Confirm the FIPS Hosted UI URL is acceptable to security review. If branding is mandatory, build a custom login UI that calls Cognito SRP/USER_PASSWORD flows directly — significantly more work and a security review surface.
- Cross-account Cognito: ALB authenticate-cognito works across accounts but the user pool ARN must be referenced and the app client registered in the auth team’s account. Coordinate IaC ownership.
- Refresh token rotation: confirm the auth team’s user pool has refresh token rotation enabled; ALB session lifetime should be ≤ refresh-token validity.
- OpEx / ORR observability: the ALB itself emits access logs to S3 — wire those to your OTel pipeline / CloudWatch. Consider AWS WAF on the ALB for rate-limiting login bursts.
- Backend team API CORS: their APIs need to allow
Origin: https://app.example.govandAuthorizationheader. - OTel egress: if the collector forwards traces outside the VPC (e.g., to a SaaS), confirm the SaaS has a GovCloud-eligible offering — most don’t. Default to CloudWatch / X-Ray-equivalent ingestion paths inside GovCloud.
Sources
- Tips for Setting Up CloudFront — AWS GovCloud (US)
- Amazon Cognito in AWS GovCloud (US)
- AWS AppConfig in AWS GovCloud (US)
- Amazon API Gateway in AWS GovCloud (US)
- Authenticate users using an Application Load Balancer
- Integrate an ALB with Amazon Cognito to authenticate users (re:Post)
- Restrictions on Lambda@Edge