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
CF-->>Browser: 302 → IDP /logout<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 |
Redirect to the IDP end_session_endpoint, clear all cookies |
| URI matches a configured public prefix | Pass through without authentication |
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)issuer from the OIDC config)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 | 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. Used as the audience claim when validating access tokens. |
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'] |
publicUriPrefixes |
string[] |
List of URI path prefixes that bypass authentication entirely. Use for static error pages or health-check endpoints. Example: ['/public/']. Set to [] to require auth for all paths. |
logoutRedirectUri |
string |
Path to redirect to after IDP logout (e.g. /public/logout.html). The full URL https://{appDomainName}{logoutRedirectUri} is sent to the IDP as post_logout_redirect_uri. Set to '' to skip the redirect and let the IDP show its own logout page. |
authErrorPageUri |
string |
Path of the page to redirect to when an unrecoverable authentication error occurs (e.g. /public/auth-error.html). The page must be listed in publicUriPrefixes and must be registered as an allowed redirect URI with the IDP. Set to '' to redirect to the distribution root instead. |
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. |
The following URIs must be registered as Redirect URIs in the IDP application registration:
https://{appDomainName}/callback — authorization code callbackhttps://{appDomainName}{authErrorPageUri} — auth error page (if set)https://{appDomainName}{logoutRedirectUri} — post-logout redirect (if set)| 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.