A Lambda@Edge viewer-request handler that protects a CloudFront distribution by enforcing authentication via the OAuth 2.0 Authorization Code Flow with PKCE (RFC 7636). It is designed to work with any OpenID Connect (OIDC)-compliant identity provider and has been specifically tested with Microsoft Entra ID (Azure AD) v2.0.
The handler intercepts every viewer request to the CloudFront distribution before it reaches the origin. Depending on the request URI and the presence of session cookies, it either passes the request through to the origin or performs one of the OAuth operations described below.
sequenceDiagram
actor Browser
participant CF as CloudFront<br/>(Lambda@Edge)
participant IDP as Identity Provider<br/>(Entra ID)
participant Origin
Note over Browser,Origin: 1 — First visit (no session)
Browser->>CF: GET /protected-page
CF-->>Browser: 302 → IDP /authorize<br/>(PKCE code_challenge, state, nonce)<br/>Set-Cookie: state, nonce, code_verifier (10 min)
Browser->>IDP: GET /authorize (user logs in)
IDP-->>Browser: 302 → /callback?code=…&state=…
Browser->>CF: GET /callback?code=…&state=…<br/>Cookie: state, nonce, code_verifier
CF->>IDP: POST /token (code + code_verifier)
IDP-->>CF: access_token, refresh_token, id_token
Note over CF: Validate state (CSRF) and nonce (replay)
CF-->>Browser: 302 → /<br/>Set-Cookie: access_token, refresh_token<br/>Clear: state, nonce, code_verifier
Note over Browser,Origin: 2 — Authenticated request (valid token)
Browser->>CF: GET /protected-page<br/>Cookie: access_token, refresh_token
Note over CF: Verify JWT (JWKS, audience, issuer, expiry)
CF->>Origin: Forwarded request
Origin-->>CF: Response
CF-->>Browser: Response
Note over Browser,Origin: 3 — Expired access token (refresh)
Browser->>CF: GET /protected-page<br/>Cookie: access_token (expired), refresh_token
CF->>IDP: POST /token (grant_type=refresh_token)
IDP-->>CF: New access_token, refresh_token
CF-->>Browser: 302 → /protected-page<br/>Set-Cookie: new access_token, refresh_token
Note over Browser,Origin: 4 — Logout
Browser->>CF: GET /logout<br/>Cookie: access_token
Note over CF: Decode access_token → extract login_hint claim
CF-->>Browser: 302 → IDP /logout?logout_hint=…<br/>Clear: access_token, refresh_token| Condition | Action |
|---|---|
GET /callback?error=… |
IDP returned an error — restart the login flow (redirect to IDP) |
GET /callback?code=…&state=… + valid session cookies |
Exchange the authorization code, validate the OIDC nonce, set session cookies, redirect to / |
GET /callback — invalid / missing params or cookies |
Redirect to auth error page |
GET /logout |
Decode the access_token cookie to extract login_hint, redirect to the IDP end_session_endpoint with logout_hint (if available) and post_logout_redirect_uri, clear all cookies |
| URI matches a configured public prefix | Pass through without authentication (only evaluated when publicPathPrefixes is configured) |
Request has valid access_token cookie |
Pass through to origin |
Request has expired access_token + refresh_token |
Exchange the refresh token for new tokens. Navigation requests are redirected to the original URI with new cookies; API requests (Accept: application/json) receive HTTP 401 with new tokens in Set-Cookie |
| Refresh token exchange fails | Redirect to IDP (restart login) |
Invalid JWT (JWTClaimValidationFailed, JWTInvalid) |
Redirect to IDP |
| Any other unhandled error | Attempt clean logout via IDP, fall back to auth error page |
| No session cookies | Redirect to IDP (start login) |
state, nonce, and a PKCE code_verifier/code_challenge pair.authorization_endpoint with the PKCE and OIDC parameters.state, nonce, and code_verifier are stored as short-lived (10 min) HttpOnly; Secure; SameSite=Lax cookies.https://{appDomainName}/callback?code=…&state=….state against the cookie (CSRF protection), exchanges the code at the token_endpoint, and verifies the nonce inside the returned id_token (replay protection).access_token and refresh_token cookies are set and the user is redirected to /.Access tokens are verified on every authenticated request using the IDP's published JWKS (jwks_uri from the OIDC discovery document). Verification checks:
clientId plus any values in opts.audienceissuer from the OIDC config plus any values in opts.issuerThe id_token is verified only at callback time to validate the nonce claim (replay protection). Its issuer and audience are always validated strictly against the values from the OIDC discovery document (config.issuer) and clientId respectively — the opts.issuer and opts.audience extensions do not apply to id_token validation. This is intentional: id_token claims are defined by the OIDC spec and should not vary, whereas access_token claims differ between providers and token versions (see Microsoft Entra ID notes below).
The OIDC well-known config and the JWKS client are cached in memory (default TTL: 60 minutes) to minimise latency on the Lambda@Edge 5-second budget. The parsed configuration options are also cached to avoid re-parsing the build-time-injected string literals on every invocation.
Because Lambda@Edge does not support environment variables, all configuration values are injected at build time by ESBuild, which replaces #PLACEHOLDER# tokens in the source with real values. In practice you pass the values to the CDK construct that builds and deploys the Lambda, and the construct handles the substitution.
OAuthOptions| Option | Type | Required | Description |
|---|---|---|---|
appDomainName |
string |
✓ | The public domain name of the CloudFront distribution (e.g. app.example.com). Used to construct redirect URIs and set cookie Domain. |
clientId |
string |
✓ | The OAuth 2.0 / Entra ID application (client) ID. Validated as the aud claim in both access_token and id_token by default. |
wellKnownUri |
string |
✓ | URL of the OIDC discovery document. For Entra ID: https://login.microsoftonline.com/{tenantId}/v2.0/.well-known/openid-configuration |
scopes |
string[] |
✓ | OAuth scopes to request. Must include openid for OIDC. Include offline_access to receive a refresh token. Example: ['openid', 'profile', 'offline_access'] |
sessionValidity |
number |
✓ | Max-Age in seconds for the refresh_token cookie. Controls the maximum session duration. The access_token cookie uses the expires_in value returned by the IDP. |
publicPathPrefixes |
string[] |
— | URI path prefixes that bypass authentication entirely. Use for static error pages or health-check endpoints. Example: ['/public/']. Omit or set to undefined to require auth for all paths. |
logoutRedirectUri |
string |
— | Full URL to redirect to after IDP logout (e.g. https://app.example.com/public/logout.html). Sent directly to the IDP as post_logout_redirect_uri. The URL must be registered with the IDP. Omit or set to '' to skip the redirect and let the IDP show its own logout page. |
authErrorPageUri |
string |
— | Full URL of the page to redirect to when an unrecoverable authentication error occurs (e.g. https://app.example.com/public/auth-error.html). The page must be listed in publicPathPrefixes and registered as an allowed redirect URI with the IDP. When omitted, the handler falls back to redirecting to https://{appDomainName}/. |
issuer |
string[] |
— | Additional iss claim values accepted during access_token validation only. The id_token issuer is always validated strictly against the OIDC discovery document value. Required for Entra ID when the app manifest's accessTokenAcceptedVersion is null or 1 — in that case the access token issuer is https://sts.windows.net/{tenantId}/ rather than the v2 OIDC issuer. Example: ['https://sts.windows.net/{tenantId}/'] |
audience |
string[] |
— | Additional aud claim values accepted during access_token validation only. The id_token audience is always validated strictly as clientId. Use when the IDP issues access tokens scoped to a resource URI rather than the client ID. Example: ['api://{resourceId}'] |
domainHint |
string |
— | Passed as domain_hint to the IDP's authorization_endpoint. Bypasses the Home Realm Discovery (HRD) email prompt for users who have multiple active logins. Useful for Entra ID tenants. Example: 'contoso.com' |
The following URIs must be registered as Redirect URIs in the IDP application registration:
https://{appDomainName}/callback — authorization code callback{authErrorPageUri} — auth error page (if configured){logoutRedirectUri} — post-logout redirect (if configured)Entra ID issues two structurally different access_token formats depending on the app registration configuration:
accessTokenAcceptedVersion in app manifest |
Access token iss claim |
opts.issuer required? |
|---|---|---|
null or 1 (default) |
https://sts.windows.net/{tenantId}/ |
Yes |
2 |
https://login.microsoftonline.com/{tenantId}/v2.0 |
No (matches OIDC discovery) |
When using v1 access tokens (the default for most tenants), you must set opts.issuer to the STS issuer URL, otherwise every access_token verification call will fail with an issuer mismatch.
The id_token issuer is always https://login.microsoftonline.com/{tenantId}/v2.0 regardless of the access token version, and is validated strictly against the OIDC discovery document — opts.issuer has no effect on id_token validation.
At /logout, the handler decodes the access_token cookie (without re-verifying the signature, as the token is about to be discarded) and reads the login_hint claim. If present, it is passed to the IDP's end_session_endpoint as logout_hint. For Entra ID this suppresses the "Are you sure you want to sign out?" confirmation page and the Home Realm Discovery prompt, providing a seamless logout experience.
The login_hint claim is the preferred_username (UPN/email) of the authenticated user. Entra ID includes it in access_tokens by default when the v2 token endpoint is used. If the claim is absent (e.g. guest accounts, service principals, or v1 tokens), the IDP logout page is shown without a hint — the logout itself still succeeds.
| Cookie | Lifetime | Purpose |
|---|---|---|
access_token |
expires_in from token response |
Carries the bearer token; verified on every request |
refresh_token |
sessionValidity (config) |
Used to silently renew the access token when it expires |
code_verifier |
10 minutes | PKCE verifier, set during login, cleared after callback |
state |
10 minutes | CSRF token, set during login, cleared after callback |
nonce |
10 minutes | Replay-protection value embedded in id_token, cleared after callback |
All cookies are set with Secure; HttpOnly; SameSite=Lax; Domain={appDomainName}.
The handler uses a layered fallback strategy to ensure the user always receives a usable response even when the IDP is unreachable:
authErrorPageUri.authErrorPageUri as the post-logout redirect, so the IDP clears its own session.getRedirectToAuthErrorPage, which clears all local cookies.All fetch calls use an AbortController with a timeout derived from the Lambda's remaining execution time minus a 500 ms grace period, preventing the handler from hanging until the Lambda hard timeout.