Introduction

When building modern applications, authentication and authorization play a crucial role. Traditionally, these checks happen on the application backend, introducing latency and extra load on your origin servers.

With Lambda@Edge, you can run custom auth logic at CloudFront edge locations, stopping unauthorized requests before they ever reach your application or S3 bucket in the first place.

In this guide, I’ll walk you through setting up Lambda@Edge to authenticate users, validate JWT tokens issued by Amazon Cognito as a Federated Identity Broker, and handle authorization. This practical, step-by-step approach will help you secure your application globally while keeping latency low by handling authentication and authorization at the edge.

What is Lambda@Edge?

Lambda@Edge is an AWS feature that lets you run Lambda functions at CloudFront edge locations worldwide. Instead of processing requests only in a central AWS region, you can intercept and modify requests and responses at the edge — reducing latency and enabling dynamic content manipulation functionality.

Why Use Lambda@Edge for Authentication & Authorization?

  • Lower Latency: Auth logic runs closer to the user.
  • Offload Origins: Unauthorized requests never hit your S3 bucket or backend.
  • Global Enforcement: CloudFront edge locations enforce the policy everywhere.
  • Flexible Policies: Validate JWT tokens, API keys, or IP allow lists.

How Lambda@Edge Works

Lambda@Edge integrates with CloudFront and can be triggered during four lifecycle events:

  • Viewer Request – when a user requests to CloudFront.
  • Origin Request – before the request is sent to your origin server.
  • Origin Response – after the origin sends a response back to CloudFront.
  • Viewer Response – before CloudFront responds to the user.

In this post, we define the process of authentication and authorization at the Viewer Request stage.

Prerequisites

You’ll be needing the following before we dive in, note that this post does not cover how to set up Amazon Cognito with an external Identity Provider (IdP). That configuration is assumed to be already in place.

  • An AWS account that has access to Lambda, CloudFront, S3, Amazon Cognito, and IAM.
  • Basic understanding of AWS Lambda and CloudFront distributions.
  • Amazon Cognito must be configured with an external Identity Provider (IdP), (e.g. Microsoft Entra ID).
  • Amazon Cognito should be capable of issuing valid JWT tokens after authenticating against external IdP.
  • You should already have Amazon Cognito User Pool created.

With that foundation, we’ll now focus entirely on how to integrate Lambda@Edge with CloudFront and Amazon Cognito to enforce authentication and authorization.

Architecture Overview

Before diving into the setup, let’s look at how authentication and authorization with Lambda@Edge and Amazon Cognito works at a high level.

alt Figure 1: High-level architecture of authentication & authorization with Lambda@Edge with Amazon Cognito

  1. User Request → A user tries to access your application via a CloudFront distribution.
  2. Lambda@Edge (Viewer Request) → The function intercepts the request before it reaches the origin.
  3. Authentication & Authorization → Lambda@Edge forwards incoming requests to Amazon Cognito and that will authenticate against an external Identity Provider (IdP). Once the user successfully signs in, Lambda@Edge validates the JWT token issued Amazon Cognito and authorize the request.
    • If no/invalid token → the user is redirected to log in again.
    • If valid → request continues.
  4. Authorized Request → Once authenticated and authorized, CloudFront fetches content from the origin S3 bucket. This flow ensures that only authenticated and authorized users can access your content while still benefiting from CloudFront’s low-latency, globally distributed edge network.

Step-by-Step Setup

Step 1: Create the IAM role

Your Lambda@Edge function needs an execution role with the right permissions and a proper trust relationship.

IAM Policy (Permissions): Attach the following policy to allow logging to CloudWatch:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": [
                "arn:aws:logs:us-east-1:*:log-group:/aws/lambda/my-lambda-auth-function:*",
                "arn:aws:logs:*:*:log-group:/aws/lambda/us-east-1.my-lambda-auth-function:*"
            ],
            "Effect": "Allow"
        },
    ]
}
  • Name the role something like LambdaEdgeExecutionRole, and attach this policy.
  • If you need your function to call other AWS services (e.g., SSM, Secrets), you’ll have to extend the policy accordingly.

Trust Relationship: Use the following trust relationship, so Lambda can assume the role:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": [
                    "lambda.amazonaws.com",
                    "edgelambda.amazonaws.com"
                ]
            },
            "Action": "sts:AssumeRole"
        }
    ]
}

Step 2: Create a Lambda Function

  • Go to the AWS Lambda Console in the N. Virginia (us-east-1) region.

  • Create a new function (e.g., MyLambdaAuthFunction).

  • Choose runtime Node.js (or Python) and select architecture x86_64.

  • Attach the IAM role you created earlier with the name LambdaEdgeExecutionRole.

  • Upload the function in the form of a zip file, here is the directory structure:

    node_modules/
    lambda_fucntion.mjs
    

Below is the example code for the auth lambda function:

// lambda_fucntion.mjs
'use strict';

import axios from 'axios';
import { decode } from 'jsonwebtoken';

const COOKIE_KEY_ID_TOKEN = 'id_token';

// I assume you already loaded the values based on your design (e.g., SSM)
// Start
var cognitoAuthDomain = "YOUR_COGNITO_AUTH_DOMAIN"; // e.g., <YUOR_CUSTOM_DOMAIN>.auth.<REGION>.amazoncognito.com
var cognitoClientId = "YOUR_COGNITO_CLIENT_ID"; // Cognito Client ID
var redirectURI = "YOUR_REDIRECT_URI"; // Your custom domain e.g., Route53 or CloudFront distribution URL
var cognitoIdentityProvider = "YOUR_COGNITO_IDENTITY_PROVIDER"; // Name of Identity provider (type OIDC)
// END

export async function handler(event) {
  const request = event.Records[0].cf.request;
  const token = getCookie(request.headers, COOKIE_KEY_ID_TOKEN);

  if (token) { // Check if token exists
    try {
      if (isTokenExpired(token)) { // Check if the token has expired, attempt to refresh it
        throw new Error("Token expired");
      } else {
        return request; // Token is valid, allow the request to pass through
      }
    } catch (err) {
      console.error('Token validation failed:', JSON.stringify(err));
      return cognitoLoginRedirect();
    }
  }

  const queryParams = getQueryParams(request.querystring);
  const state = queryParams.state;
  const code = queryParams.code;

  // Check:
  // 1. If the request contains an authorization code, exchange it for tokens and set them as cookies in the response.
  // 2. If there is no authorization code, redirect the user to the Cognito login.
  if (code && state) {
    const body = {
      grant_type: 'authorization_code',
      code,
      client_id: cognitoClientId,
      redirect_uri: redirectURI,
    };
    try {
      const tokens = await requestCognitoToken(body);
      return setTokenCookiesAndRedirect(tokens);
    } catch (err) {
      console.error('Error retrieving tokens:', JSON.stringify(err));
      return {
        status: '500',
        statusDescription: 'Internal Server Error',
        body: 'Authentication failed: ' + JSON.stringify(err),
      };
    }
  } else {
    return cognitoLoginRedirect();
  }
}

function setTokenCookiesAndRedirect(tokens) {
  const idTokenExp = decode(tokens.id_token, { complete: true }).payload.exp;
  const expiryInSeconds = idTokenExp ? idTokenExp - Math.floor(Date.now() / 1000) : 86400; // Default to 24 hour if no exp in id_token
  const expiresString = new Date(Date.now() + expiryInSeconds * 1000).toUTCString();
  return {
    status: '302',
    statusDescription: 'Found',
    headers: {
      location: [{
        key: 'Location',
        value: redirectURI,
      }],
      'set-cookie': Object.entries(tokens).map(([key, value]) => {
        return {
          key: 'Set-Cookie',
          value: `${key}=${value}; Domain=${getDomain(redirectURI)}; Secure; Path=/; Max-Age=${expiryInSeconds}; Expires=${expiresString};`,
        };
      }),
    },
  };
}

function cognitoLoginRedirect() {
  const state = getRandomString(32);
  const queryString = Object.entries({
    response_type: 'code',
    client_id: cognitoClientId,
    identity_provider: cognitoIdentityProvider,
    scope: ['openid', 'profile', 'email'].join(' '),
    redirect_uri: redirectURI,
    state,
  })
    .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
    .join('&');

  const response = {
    status: '302',
    statusDescription: 'Found',
    headers: {
      location: [{
        key: 'Location',
        value: `https://${cognitoAuthDomain}/oauth2/authorize?${queryString}`,
      }],
    },
  };
  return response;
}

async function requestCognitoToken(body) {
  const tokensUrl = `https://${cognitoAuthDomain}/oauth2/token`;
  try {
    const response = await axios.post(tokensUrl, body, {
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
    });
    return response.data;
  } catch (err) {
    console.error('Error fetching tokens:', JSON.stringify(err));
    throw new Error(`Error fetching tokens: ${err.message}`);
  }
}

// Below are the utility functions you need to create, as I have skipped them from the block:
//
// 1. isTokenExpired(token)
//    → Extracts `payload.exp` from the JWT and validates expiry.
//
// 2. getRandomString(length)
//    → Generates a random alphanumeric string of the given length.
//
// 3. getQueryParams(queryString)
//    → Parses query parameters from a URL query string into an object.
//
// 4. getDomain(url)
//    → Extracts the base domain (e.g., example.com) from a full URL.
//
// 5. getCookie(headers, cookieName)
//    → Extracts the cookie by name from CloudFront request headers.

Note: The above code has some redundancies and is written for clarity rather than efficiency. It demonstrates the high-level idea of the authentication and authorization concept with Amazon Cognito and JWT tokens validation. You can optimize it or extend it to support scopes, roles, or custom claims based on your application needs.

Authentication & JWT Flow with Lambda@Edge: The following points explain how the request flow works:

  1. Initial Authentication Request

    • The user accesses your application.
    • Your Lambda@Edge function triggers a request to authenticate the user.
    • The user is redirected to the Identity Provider (IdP) to log in.
  2. Receive Authorization Code

    • After successful login, the IdP returns an authorization code to your application.
  3. Exchange Code for JWT Tokens

    • Your Lambda function takes the authorization code and requests JWT tokens from Amazon Cognito.
    • Cognito responds with ID token, access token, and refresh token.
  4. Store Tokens in Cookie & Redirect

    • The Lambda function stores the received tokens in secure cookies.
    • The user is redirected to the original URL.
  5. Subsequent Requests – Token Validation

    • When the user returns, the Lambda@Edge function sees the JWT tokens in the cookies.
    • It validates the tokens to ensure they are authentic and not expired.
    • Once validated, the request is allowed to pass through to your application.

Below is the package.json file for the required packages for the lambda function:

{
  "name": "my_lambda_auth",
  "version": "1.0.0",
  "author": "Yougeshwar Khatri",
  "license": "MIT",
  "description": "Layer for Lambda@Edge function",
  "scripts": {
    "zip": "rm -rf lambda_content.zip && zip -r lambda_content.zip ./node_modules ./lambda_function.mjs"
  },
  "devDependencies": {
    "axios": "^1.11.0",
    "jsonwebtoken": "^9.0.2"
  }
}

Build and Package: Run the following commands to install dependencies and create a zip file for deployment

# Install project dependencies
npm install

# Build and create a zip package
npm run zip

Step 3: Create an S3 Bucket (Origin)

  • Create an S3 bucket (e.g., MyLambdaEdgeAuthBucket).
  • Upload sample files (e.g., index.html).
  • Keep the bucket private.

Step 4: Create a CloudFront Distribution

  • Create a new distribution with the name MyDistribution

  • Set your S3 bucket as the origin.

  • Save the distribution.

  • Edit the distribution again to add the behavior

  • Create behavior

    • Path pattern: Default (*)
    • Origin: Select S3 bucket origin
    • Viewer protocol policy: Enable Redirect HTTP to HTTPS.
    • Allowed HTTP methods: GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE
    • Function associations: Viewer request
      • Function type: Lambda@Edge
      • Function ARN with version # It will be updated in next step

Step 5: Deploy Lambda@Edge

  • Open your Lambda function in us-east-1.
  • Select Actions → Deploy to Lambda@Edge.
  • Attach it to your CloudFront distribution on the Viewer Request event, and that will update the version in CloudFront distribution behavior.
  • Deploy.

Step 6: Test Your Setup

Testing auth with Amazon Cognito requires a browser flow because tokens are issued after a successful login.

  1. Open a browser and access your custom domain or CloudFront URL (e.g., https://d1234abcdef.cloudfront.net/).
  2. If the user is not authenticated, the application should redirect them to external IdP login page through Amazon Cognito.
  3. After login, Amazon Cognito issues the JWT tokens and redirects back to your CloudFront URL with the tokens included in secure cookies.
  4. Lambda@Edge validates the token at the Viewer Request stage:
    • If valid → the request passes and content is served.
    • If invalid/expired → the user sees a 403 Forbidden response.

Logging & Debugging

Debugging Lambda@Edge is often tricky, because logs are not tied to the AWS region where your Lambda@Edge is deployed (us-east-1). Instead:

  • CloudWatch log groups are always created in request origin region, not in N. Virginia (us-east-1). The log groups appear in the AWS region closest to the request’s origin (e.g., if a user requests from Germany, logs will be in eu-central-1).

  • The log group follows the naming format:

    /aws/lambda/us-east-1.<function-name>
    

    So if your function is called MyLambdaAuthFunction, the logs will be stored in the closest request’s origin region under:

    /aws/lambda/us-east-1.MyLambdaAuthFunction
    

Limitations

Keep these in mind when designing your auth logic:

  • Deployment Region – Functions must be created in us-east-1.
  • No Environment Variables – Use hard-coded values or versions or SSM.
  • Package Size – Max 50 MB uncompressed, 1 MB inline zip.
  • Execution Timeouts5s for viewer events, 30s for origin events.
  • Runtimes – Only Node.js and Python are supported.
  • Architecture – Only x86_64 (no ARM/Graviton).
  • File System – Read-only /tmp directory, max 512 MB.
  • Limited IAM Access – Avoid heavy AWS API calls.

Conclusion

By moving auth checks to the edge, you:

  • Block unauthorized requests early.
  • Reduce load on your backend.
  • Improve latency and scalability.

Lambda@Edge is not a full replacement for centralized identity providers, but it’s an excellent way to add a first line of defense at the network edge.

Start small — implement a static header check — then expand into JWT validation, deeper Cognito integration. Later, you can build on this with the refresh_token concept for more advanced scenarios.

References

Below are some useful resources and references that inspired this guide and provide additional context on Lambda@Edge, Amazon Cognito and external IdP (Azure AD) auth process.