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:

  1. Cognito — manages the user pool and handles Google OAuth
  2. CloudFront — serves the site and routes requests
  3. 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 — $LATEST won’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.