PCG logo
Article

Protecting Lambda URLs with Cognito, IAM, Lambda@Edge and CDK

TL;DR: the complete code to implement such a solution can be found hereExternal Link.

Lambda URLsExternal Link provide a simple way to expose your Lambda functions via direct HTTP(S) requests. Compared to the more robust API Gateway approachExternal Link, they offer several advantages:

  • Minimal configuration effort
  • No additional cost
  • 15-minute request timeout (1)

However, this simplicity comes with trade-offs. Lambda URLs lack advanced features such as caching and throttling, and they only offer a single access control mechanism: IAM-based authenticationExternal Link. This authentication method is easy to implement for service-to-service communication, but not simple to use for direct users.

In this article, we’ll look at how to secure Lambda URLs using IAM access control, while aiming for a solution that remains simple and easy to manage. The approach outlined here relies on CloudFront and Lambda@Edge.

IAM Access Control

By default, Lambda URLs are not protected: anyone with the URL can make an HTTP request. This can be prevented by using the AWS_IAM AuthTypeExternal Link mode.

When using this mode, all requests must contain a header with a signed hash of the request content. This hash will be used to authenticate the sender and check if they have the necessary IAM permissions to make that request. The signing can be done either using custom codeExternal Link, or by using one of the AWS SDKs, which all contain several methods to simplify this process.

image-18321bb401fe

The next question is: how to authenticate the user and add this authentication header? If the request is sent by another service, the answer is simple: use the AWS SDK to automatically add this signature and give that service the necessary IAM permissions.

However, if you expect your users to call the Lambda URL from a browser, then you need an alternative since there is no out-of-the-box way to sign in a user into IAM and generate that signature directly in the browser.

Solution

We will implement a solution that relies on the following services:

  • IAM: authentication and authorization
  • Cognito: user account management and login
  • Lamda@Edge: to check the user's Cognito token and sign the request using an IAM Signature V4
  • CloudFront: call the Lambda@Edge for each request
  • CDK: infrastructure deployment

We will use TypeScript for the CDK code and the Lambda, but stick with plain JavaScript for the Lambda@Edge, since the CDK constructExternal Link allowing us to deploy it does not include any advanced packaging features nor TypeScript support.

image-827753434305

Process

  1. The user accesses the Cognito self-hosted UI and signs in to obtain a token
  2. They send a request to the CloudFront endpoint, with the Authentication header set with the Cognito token
  3. The Lambda@Edge intercepts the requests and verifies the Cognito token
  4. The Lambda@Edge forwards the request to the backend Lambda and returns the result to the user

Implementation

You can find the complete code and file structure described here on my GitHub repositoryExternal Link.

The backend Lambda

We will start by creating a simple Lambda function that returns a small JSON, and the associated CDK deployment code. We will be using the NodejsFunctionExternal Link construct which simplifies Lambda deployments with several useful features, including the capacity to automatically transpile TypeScript into JavaScript, along with the dependencies, during the packaging.

javascript
Code Copied!copy-button
// In new file lambda/handler.ts
export const handler = async () => {
    return {
        statusCode: 200,
        body: JSON.stringify({ message: 'Hello, world!' }),
    };
};
javascript
Code Copied!copy-button
// In a file called lib/cdk-stack.ts, containing all the AWS infrastructure deployment code
const simpleLambda = new lambdaNode.NodejsFunction(this, 'simpleLambda', {
	entry: 'lambda/handler.ts',
	handler: 'handler',
	runtime: lambda.Runtime.NODEJS_18_X,
	functionName: 'simpleLambda'
});

We will then add a Lambda URL to the backend Lambda, to make it directly reachable via HTTP requests.

javascript
Code Copied!copy-button
// lib/cdk-stack.ts
const lambdaUrl = simpleLambda.addFunctionUrl({
	authType: lambda.FunctionUrlAuthType.AWS_IAM,
});

Authentication and IAM Signing via Lambda@Edge

Next, we will write the Lambda@Edge code. This function has two main purposes:

  • check the Cognito token to verify if the user is authenticated
  • sign the request using the IAM v4 signature and pass it along

This function will be written in JavaScript, since the EdgeFunctionExternal Link CDK construct does not have the capacity to handle TypeScript. We also need to add the corresponding package.json file in the same folder to include the dependencies during the deployment. Here at the key parts of this function. Refer to the GitHub repositoryExternal Link for the complete code and package.json file.

javascript
Code Copied!copy-button
// In a new file lambda-edge/authEdge.js 

[...]

// We retrieve the credentials corresponding the Lambda's IAM role. This role should have the correct "InvokeFunctionUrl" permission, allowing it to invoke our "simple Lambda"
const {
    AWS_ACCESS_KEY_ID,
    AWS_SECRET_ACCESS_KEY,
    AWS_SESSION_TOKEN,
} = process.env;


[...]

// We prepare the request signing parameters, including the IAM role credentials
const sigv4 = new SignatureV4({
    service: 'lambda',
    region: 'eu-central-1', // Use your own region
    credentials: {
        accessKeyId: AWS_ACCESS_KEY_ID,
        secretAccessKey: AWS_SECRET_ACCESS_KEY,
        sessionToken: AWS_SESSION_TOKEN,
    },
    sha256: Sha256,
});

module.exports.handler = async (event) => {
    
    [...]
    
    // We use the Cognito IDs, stored in the SSM Parameter store, to verify the token retrieved from the Authentication header
    try {
        const COGNITO_USER_POOL_ID = await getParameter('/publisher/user-pool-id');
        const COGNITO_CLIENT_ID = await getParameter('/publisher/user-pool-client-id');

        const verifier = CognitoJwtVerifier.create({
            userPoolId: COGNITO_USER_POOL_ID,
            tokenUse: "id",
            clientId: COGNITO_CLIENT_ID,
        });

        await verifier.verify(token);
    } catch (err) {
		[...]
    }

	// We build the Signing options, based on the request's details	 
    const apiUrl = new URL(`https://${cfRequest.origin.custom.domainName}${cfRequest.uri}`);
    const signV4Options = {
        method: cfRequest.method,
        hostname: apiUrl.host,
        path: apiUrl.pathname + (cfRequest.querystring ? `?${cfRequest.querystring}` : ''),
        protocol: apiUrl.protocol,
        query: cfRequest.querystring,
        headers: {
            'Content-Type': headers['content-type'][0].value,
            host: apiUrl.hostname, // compulsory
        },
    };

    try {
	    // Finally, we sign the request by adding the hash in the request headers and we pass along it to the Origin (our simple Lambda)
        return await signAndForwardRequest(cfRequest, signV4Options, apiUrl);
    } catch (error) {
	    [...]
    }
};

async function signAndForwardRequest(cfRequest, signV4Options, apiUrl) {
	// If there is a body in the request (for POST/PUT requests), we include it in the signature, and we adapt the Content-Length header accordingly
    if (cfRequest.body && cfRequest.body.data) {
        let body = cfRequest.body.data;
        if (cfRequest.body.encoding === 'base64') {
            body = Buffer.from(body, 'base64').toString('utf-8');
        }

        signV4Options.body = typeof body === 'string' ? body : JSON.stringify(body);
        signV4Options.headers['Content-Length'] = Buffer.byteLength(signV4Options.body).toString();
    }

	// We sign and forward the request to the Origin
    const signed = await sigv4.sign(signV4Options);
    const result = await axios({
        ...signed,
        url: apiUrl.href,
        timeout: 5000,
        data: signV4Options.body,
    });

    return {
        status: '200',
        statusDescription: 'OK',
        body: JSON.stringify(result.data),
    };
}

We also need the CDK code, which will deploy it to all CloudFront Edges (this can take up to 15 min), and give it permission to invoke the backend Lambda.

javascript
Code Copied!copy-button
// lib/cdk-stack.ts
const authFunction = new cloudfront.experimental.EdgeFunction(this, 'AuthLambdaEdge', {
	handler: 'authEdge.handler',
	runtime: lambda.Runtime.NODEJS_16_X,
	code: lambda.Code.fromAsset(path.join(__dirname, '../lambda-edge'), {
		bundling: { // Include the function's dependencies into the final package
			command: [
				"bash",
				"-c",
				"npm install && cp -rT /asset-input/ /asset-output/",
			],
			image: lambda.Runtime.NODEJS_16_X.bundlingImage,
			user: "root",
		},
	}),
	currentVersionOptions: {
		removalPolicy: cdk.RemovalPolicy.DESTROY
	},
	timeout: cdk.Duration.seconds(7),
});

authFunction.addToRolePolicy(new PolicyStatement({
	sid: 'AllowInvokeFunctionUrl',
	effect: Effect.ALLOW,
	actions: ['lambda:InvokeFunctionUrl'],
	resources: [simpleLambda.functionArn],
	conditions: {
		"StringEquals": { "lambda:FunctionUrlAuthType": "AWS_IAM" }
	}
}));

CloudFront

We also need a CloudFront distribution to receive all requests, apply caching if necessary and pass the requests to the Lambda@Edge function before sending them to the Origin (the backend Lambda).

javascript
Code Copied!copy-button
// lib/cdk-stack.ts
const cfDistribution = new cloudfront.CloudFrontWebDistribution(this, 'CFDistribution', {
	originConfigs: [
		{
			customOriginSource: {
				domainName: this.getURLDomain(lambdaUrl),
				originProtocolPolicy: cloudfront.OriginProtocolPolicy.HTTPS_ONLY,
			},
			behaviors: [{
				isDefaultBehavior: true,
				allowedMethods: cloudfront.CloudFrontAllowedMethods.ALL,
				lambdaFunctionAssociations: [{ // triggers the Lambda@Edge function before forwarding to the origin
					eventType: cloudfront.LambdaEdgeEventType.ORIGIN_REQUEST,
					lambdaFunction: authFunction.currentVersion,
					includeBody: true // make sure the body of POST/PUT requests can be forwarded as well
				}],
			}],
		}
	],
});

Authentication with Cognito

We will now add a Cognito user poolExternal Link to register our users, and a clientExternal Link to allow them to log in. The following code is only showing the key parts for our use case. Refer to the file in the GitHub repositoryExternal Link to see the full code.

Note: We will use the URL of the Cognito-hosted UI as a callback URL to avoid having to set up one ourselves for this tutorial. You would normally have to deploy a frontend UI, to which Cognito would redirect after authentication.

javascript
Code Copied!copy-button
// lib/cdk-stack.ts

const userPool = new cognito.UserPool(this, 'UserPool', {
	userPoolName: 'UserPool',
	[...]
});

const userPoolDomain = new cognito.CfnUserPoolDomain(this, 'UserPoolDomain', {
	domain: `000011114444-iam-login`, // Replace the numerical prefix with some unique value such as your account number for instance
	userPoolId: userPool.userPoolId,
});

// The URL of the Cognito-hosted UI for user login/logout
const poolHostedUIUrl = `https://${userPoolDomain.domain}.auth.${this.region}.amazoncognito.com`;

const userPoolClient = new cognito.UserPoolClient(this, 'UserPoolClient', {
	userPool,
	[...]
	oAuth: {
		[...]
		callbackUrls: [poolHostedUIUrl], // we redirect Cognito to its own UI URL
	    logoutUrls: [poolHostedUIUrl],
	},
});

SSM Parameters

The final step is to make the Cognito user pool and client IDs available to the Lambda@Edge. Since Lambda@Edge does not support environment variables, the best alternative is to put those values in the SSM Parameter Store.

javascript
Code Copied!copy-button
const userPoolIdParam = new ssm.StringParameter(this, 'UserPoolIdParam', {
	parameterName: '/lambda-url-iam/user-pool-id',
	stringValue: userPool.userPoolId,
});

const userPoolClientIdParam = new ssm.StringParameter(this, 'UserPoolClientIdParam', {
	parameterName: '/lambda-url-iam/user-pool-client-id',
	stringValue: userPoolClient.userPoolClientId,
});

// We give the Lambda@Edge permission to read those values
authFunction.addToRolePolicy(new PolicyStatement({
	effect: Effect.ALLOW,
	actions: ['ssm:GetParameter'],
	resources: [
		userPoolIdParam.parameterArn,
		userPoolClientIdParam.parameterArn,
	],
}));

Deployment

Now that we have everything ready, the next step is to actually deploy this setup. If you have an AWS account, you can try it by using my GitHub repositoryExternal Link. Here are the steps, assuming you have a recent version of NodeJS installed locally:

  1. Clone the repositoryExternal Link
  2. Cd inside the repository, then run npm install
  3. Make sure that you are logged in into your AWS account/profile in your CLI
  4. Check your AWS region, the code expects to run in eu-central-1. If you want to use another region, modify it in the bin/cdk.ts and lambda-edge/authEdge.js
  5. Run npx cdk deploy CdkStack, and select yes when prompted by CDK
  6. Login into your AWS account console, and find the user pool called “UserPool”.
  7. Create a new user in that user pool. If you don't know how, use the AWS tutorialExternal Link.

If the installation is successful, you will obtain several outputs. Take note of the CloudFrontDistributionURL and CognitoUIUrlWithParameters outputs, as we will need them for the next steps.

Testing

We will now try to call the Lambda function:

Step 1: Use the URL of the CognitoUIUrlWithParameters output in a browser.

image-de587e99a168

Step 2: Sign-in using the credentials of the user you created in the Cognito user pool

Step 3: This will redirect you to a blank page, where you can find the idToken value in the URL parameters

image-fd14619d3557

Step 4: Using Postman or Curl, send a GET request to the URL in the CloudFrontDistributionURL output, with the Authorization header set to Bearer {idToken}

image-6be1ae1f19d8

If all goes well, you will receive a “Hello World!” JSON message.

image-ca59a09f16b4

Conclusion

Protecting a Lambda URL can be achieved using familiar AWS services like CloudFront and Lambda@Edge, making the process relatively straightforward in terms of concept and implementation. The most challenging aspect lies in signing the HTTP request. Any misconfiguration—such as missing or incorrect headers—can lead to frustrating “403 Forbidden” errors with little to no feedback or error details.

However, adding CloudFront and Lambda@Edge introduces additional complexity and configuration overhead. While Lambda URLs are designed to be simple and cost-effective, this solution undermines those benefits by increasing both the complexity and cost. In particular, the extra configuration steps and increased costs bring this solution closer to the cost of API Gateway.

Previously, one of the key advantages of Lambda URLs over API Gateway was the extended request timeout of 15 minutes, compared to API Gateway's 29-second limit. However, with the recent introduction of extended timeouts in API Gateway, this benefit is now less relevant. API Gateway now offers extended timeout options for specific use cases, making the decision to use Lambda URLs less compelling if long-running requests are a priority.

In essence, leveraging Lambda URLs with CloudFront and Lambda@Edge is only advisable when you already have CloudFront integrated into your infrastructure and don’t want to introduce API Gateway. If API Gateway isn't already part of your setup, this approach could help avoid the costs and effort of maintaining an additional service. With the timeout feature no longer being a significant differentiator however, API Gateway may now be a better fit in most scenarios where you need more built-in features like authentication, throttling, and caching.

(1) this might be less relevant, since API Gateway has recently introducedExternal Link the option to extend the timeout beyond the usual 29s for specific use cases.


Services Used

Continue Reading

Case Study
Education
Cloud Migration
Education
Transforming Robotics Research: RCCL's Migration to AWS

Discover how the Robotics, Automatic Control, and Cyber-Physical Systems Laboratory (RCCL) leveraged AWS to support their advanced research in robotics and IoT data analysis. Learn how they managed real-time sensor data, machine learning techniques, and MATLAB computations on a scalable, secure platform.

Learn more
Article
Securing APIs in an AWS Cloud Environment

In 2019, a major financial services company, Capital One, experienced a severe security breach caused by a misconfigured API. This breach exposed the personal data of over 100 million customers, including sensitive information such as names, addresses, and social security numbers. The incident not only inflicted substantial financial and reputational damage on the company but also underscored the critical importance of securing APIs in today’s interconnected world.

Learn more
Article
AWS Lambda: Avoid these common pitfalls

It's a great offering to get results quickly, but like any good tool, it needs to be used correctly.

Learn more
Case Study
Financial Services
Cloud Migration
The VHV Group's Cloud Journey - Strategy for Success

How does an insurance company with more than 4,000 employees balance compliance, modernization, and cost efficiency?

Learn more
See all

Let's work together

United Kingdom
Arrow Down