Authentication at the Edge with Lambda@Edge
One of the trickier parts of this site is protecting the photo gallery. The site is fully static — there’s no server to check credentials against. So the authentication has to happen at the CDN level.
The Setup
The solution uses three AWS services working together:
- Cognito — manages the user pool and handles Google OAuth
- CloudFront — serves the site and routes requests
- Lambda@Edge — runs on every request to protected paths and checks for a valid JWT
How It Works
When someone requests a gallery image, the request hits CloudFront first. Before CloudFront fetches the image from S3, it triggers a Lambda@Edge function on the viewer-request event:
exports.handler = async function(event) {
const request = event.Records[0].cf.request;
const cookies = parseCookies(request.headers);
const token = cookies['id_token'];
if (!token) {
return redirectToLogin(request);
}
try {
await verifyToken(token);
return request; // allow through
} catch (err) {
return redirectToLogin(request);
}
};
The function checks for an id_token cookie, validates it against Cognito’s JWKS endpoint, and either lets the request through or redirects to the login page.
The Cost Question
Lambda@Edge charges per request. For a personal site with a handful of users, it’s essentially free. But if bots start hammering your endpoints, it could add up. I keep a budget alarm set at $5/month and have a documented procedure for pulling the Lambda@Edge association if things go sideways.
Lessons Learned
- Lambda@Edge functions must be deployed to
us-east-1, regardless of where your other infrastructure lives - You need to publish a numbered version —
$LATESTwon’t work - Propagation to all edge locations takes 5-15 minutes after any change
- CloudWatch logs appear in the AWS region closest to the user, not us-east-1. If you’re on the west coast, look in
us-west-2
Gotchas I Hit Getting This Working
The sed backreference bug. The deploy script bakes the Cognito login URL into the Lambda package using sed. The login URL contains & characters (query string separators). In sed, & in the replacement string means “the entire matched string” — so every & in the URL was being replaced with the full pattern match, producing a completely broken URL. The fix is to escape ampersands before passing them to sed:
ESCAPED_LOGIN_URL=$(echo "$COGNITO_LOGIN_URL" | sed 's/&/\\&/g')
This one produced a redirect_mismatch error from Cognito that took a while to trace back to the corrupted URL.
The callback URL must use .html, not a trailing slash. The redirect URI registered with Cognito was https://example.com/auth/callback/. Hugo with uglyURLs = true generates auth/callback.html — a flat file, not a directory. S3 + CloudFront don’t serve index.html for subdirectory paths (only the root DefaultRootObject is handled that way). So GET /auth/callback/ returned 404, the callback page never loaded, the id_token cookie was never set, and images silently failed to appear. The fix was changing the redirect URI to /auth/callback.html everywhere: Cognito App Client, Google OAuth, .env, hugo.toml, and the baked Lambda URL.
aws cognito-idp update-user-pool-client resets everything you don’t pass. When I updated the App Client’s callback URL directly with the CLI, I only passed --callback-urls and --logout-urls. This silently reset AllowedOAuthFlows, AllowedOAuthScopes, SupportedIdentityProviders, and AllowedOAuthFlowsUserPoolClient to their defaults — effectively disabling login. The error was unauthorized_client. Always use aws cloudformation deploy on the Cognito stack instead of the CLI directly; the CloudFormation template passes all settings together.
Every aws cloudformation deploy must include all parameters. CloudFormation resets any omitted parameter to its default value (usually empty string). I ran a stack update with only EdgeAuthFunctionArn to wire up the Lambda, forgetting to include DomainName and CertificateArn. CloudFront reverted to its default *.cloudfront.net certificate, and the custom domain showed an SSL error. Always pass every parameter you’ve ever set on every deploy.