React SPA + Keycloak on GovCloud — Serverless Variant

Summary

Variant of the baseline ALB + Fargate design that drops both Fargate target groups in favour of Lambda target groups behind the same ALB. Pay-per-request, zero idle compute, no container fleet — but constrained by the 1 MB ALB→Lambda response cap (AWS ELB docs).

The OIDC enforcement layer must stay on the ALB. ALB is the only FedRAMP-High-in-GovCloud service that performs the OIDC authenticate-oidc 302 redirect dance at the edge. API Gateway has Cognito and JWT authorizers but no equivalent IdP-redirect listener action, so it cannot replace the ALB as the front-door auth enforcer in this design.

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)"]
        FEIndex["Lambda — / and /index.html<br/>(returns index.html inline)"]
        FERedirect["Lambda — /assets/*<br/>(mints presigned S3 URL, 302)"]
        API["Lambda — /api/*"]
        S3[("S3 SPA bundle (private)")]
    end

    User -->|HTTPS| WAF --> ALB
    ALB -->|unauthenticated| KC
    ALB -->|authenticated, GET /| FEIndex
    ALB -->|authenticated, GET /assets/*| FERedirect
    FERedirect -->|302 presigned| User
    User -.->|follow redirect| S3
    ALB -->|/api/*| API
    API -->|JWKS validate| KC
    FEIndex -->|read index.html| S3

Listener rules on the same HTTPS listener:

RulePathAuth actionTarget
1/api/*none (SPA attaches its own bearer JWT)API Lambda target group
2/assets/*authenticate-oidcAsset-redirect Lambda target group
3/oauth2/idpresponse(handled by ALB internally)
4/logoutnoneFixed-response listener rule (clears AWSELBAuthSessionCookie-*, 302 to Keycloak end_session_endpoint)
5default /*authenticate-oidcIndex Lambda target group

What changes from the baseline

LayerBaseline (Fargate)Serverless variant
Frontend servingECS Fargate Nginx, always-on, ≥1 task per AZTwo Lambdas: tiny index.html server + presigned-URL redirector for /assets/*
Backend APIECS Fargate API, always-onLambda functions (one router or one per route group)
Idle costFargate task-hours per AZ + ALBALB only; Lambda has $0 idle
Cold startNone100–800 ms cold start; mitigate with provisioned concurrency on the index Lambda
Bundle delivery limitNone1 MB ALB→Lambda response cap
Deploy unitContainer image + task-definition revisionLambda alias (faster rollback)

The four sequence diagrams in the baseline doc (first load, silent SSO, refresh, logout) are unchanged. The SPA still bootstraps keycloak-js, still uses Authorization Code + PKCE, still attaches Authorization: Bearer <jwt> to API calls. Only the box behind the ALB changed.

The 1 MB limit and how to live with it

ALB-invoked Lambda is hard-capped at 1 MB request body and 1 MB response body (AWS ELB Lambda functions docs). For binary content the response is base64-encoded, which inflates payloads ~33% before the cap is checked. That is fine for index.html and small JSON API responses but breaks for typical SPA vendor bundles, fonts, and source maps.

Two patterns considered:

  1. Lambda redirector + S3 presigned URLs (recommended). The /assets/* listener rule routes to a small Lambda that:

    • confirms the ALB session header (x-amzn-oidc-data) is present (defense in depth — ALB only forwards on authenticated rules anyway),
    • mints a 60-second presigned GetObject URL for the requested key,
    • returns a 302 to that URL.

    The browser then fetches the bytes directly from S3. The asset bucket stays private (BlockPublicAcls, no website hosting); the only way to obtain a presigned URL is to have already authenticated through the ALB. This satisfies the “auth required before any frontend asset is served” requirement — an unauthenticated user has no path to a URL.

  2. Lambda response streaming — not viable. awslambda.streamifyResponse is only supported through Lambda Function URLs, not ALB targets (AWS docs). Function URLs lack OIDC enforcement, defeating the design goal.

Asset-redirect Lambda — sequence

sequenceDiagram
    autonumber
    participant U as Browser
    participant ALB as ALB (OIDC enforced)
    participant L as Asset-redirect Lambda
    participant S3 as S3 (private bucket)

    U->>ALB: GET /assets/main.abcd1234.js (ALB session cookie present)
    ALB->>L: Forward request, add x-amzn-oidc-* headers
    L->>L: Validate x-amzn-oidc-data signature (optional defense-in-depth)
    L->>S3: presign_url("assets/main.abcd1234.js", expires=60s)
    S3-->>L: presigned URL
    L-->>ALB: 302 Location: <presigned URL>, Cache-Control: no-store
    ALB-->>U: 302
    U->>S3: GET <presigned URL>
    S3-->>U: bundle bytes (any size, no Lambda involved)

Tradeoffs

Wins

  • Zero idle compute cost; pay-per-request fits a low-volume internal gov app.
  • No container image lifecycle, no task-definition revisions, no Fargate platform-version upgrades, no per-AZ minimum capacity.
  • Smaller blast-radius unit of deploy (one Lambda alias) — easier to roll back than a Fargate service.

Losses / risks

  • Cold starts on the /index.html Lambda are user-visible on first hit. Mitigation: provisioned concurrency = 1 (cheap) or warm via a 5-minute EventBridge ping.
  • The presigned-URL hop adds one round-trip per asset. HTTP/2 multiplexing on the S3 endpoint absorbs most of it, but Lighthouse TTFB will look worse than serving from Nginx.
  • 1 MB cap forces the redirect pattern. Teams that expect to drop a large WASM blob, source-map, or .tar.gz through a Lambda will hit walls — document this clearly for app teams.
  • Lambda inside a VPC adds ENI attachment time on cold start (~100 ms with Hyperplane ENIs, occasionally more).
  • API Lambdas inherit the 1 MB request cap — file uploads through /api/* need a presigned-PUT pattern (browser → S3 directly), not multipart through the API.

When NOT to pick this variant

  • If the API has long-running requests (>29 s ALB idle timeout default, or >15 min Lambda max), keep Fargate.
  • If you need WebSocket or server-sent events for the API — ALB→Lambda explicitly does not support WebSocket upgrades (ELB docs).
  • If the agency security posture forbids S3 presigned URLs (some do).
  • If the SPA bundle’s individual chunks routinely exceed ~700 KB gzipped and you cannot introduce code-splitting boundaries.

Open questions specific to this variant

  1. Provisioned concurrency on the index Lambda — yes/no, and what concurrency value? Trades a small monthly bill for predictable first-hit latency.
  2. Presigned URL TTL — 60 s is conservative; some agencies require shorter or forbid presigned URLs entirely.
  3. VPC vs non-VPC Lambda — the API Lambda needs VPC if it talks to private resources (RDS, internal services, Keycloak over PrivateLink). The two frontend Lambdas only need S3, so they can be non-VPC and skip ENI cold-start cost.
  4. CloudWatch log retention — Lambda creates per-function log groups; set retention at provisioning time or pay forever.
  5. S3 access path from Lambda — Gateway VPC Endpoint (free) for S3 if the Lambda runs in VPC; nothing extra otherwise.

Sources