Protecting Lambda URLs with Cognito, IAM, Lambda@Edge and CDK
TL;DR: the complete code to implement such a solution can be found here.
Lambda URLs provide a simple way to expose your Lambda functions via direct HTTP(S) requests. Compared to the more robust API Gateway approach, 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 authentication. 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 AuthType mode.
When using this mode, all requests will have to contain a header containing 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 code, or by using one of the AWS SDKs which all contain several methods to make this process easier.
The next question is: how to authenticate the user and add this authentication header? If the request is sent by another service, then 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 to generate that signature directly in the browser.
Solution
We will be implementing 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 be using Typescript for the CDK code and the Lambda, but stick with plain Javascript for the Lambda@Edge, since the CDK construct allowing us to deploy it does not include any advanced packaging features nor Typescript support.
Process
- The user access the Cognito self-hosted UI and signs-in to obtain a token
- They send a request to the CloudFront endpoint, with the Authentication header set with the Cognito token
- The Lambda@Edge intercepts the requests and verifies the Cognito token
- 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 repository.
The backend Lambda
We will start by creating a simple Lambda function, returning a small JSON, and the associated CDK deployment code. We will be using the NodejsFunction 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.
// In new file lambda/handler.ts
export const handler = async () => {
return {
statusCode: 200,
body: JSON.stringify({ message: 'Hello, world!' }),
};
};// 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.
// 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 EdgeFunction 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. We will only look here at the key parts of this function. Have a look at the Github repository for the complete code and package.json file.
// 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.
// 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 they are sent to the Origin (the backend Lambda).
// 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 pool to register our users, and a client to allow them to login. The following code is only showing the key parts for our use case. Have a look at the file in the Github repository to see the full code.
Note: we will be using 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.
// 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.
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 repository. Here are the steps, assuming that you have a recent version of NodeJS installed locally:
- Clone the repository
- Cd inside the repository, then run npm install
- Make sure that you are logged in into your AWS account/profile in your CLI
- 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
- Run npx cdk deploy CdkStack, and select yes when prompted by CDK
- Login into your AWS account console, and find the user pool called “UserPool”.
- Create a new user in that user pool. If you don’t know how, use the AWS tutorial.
If the installation is successful, you will obtain several outputs. Take note of the CloudFrontDistributionURL and CognitoUIUrlWithParameters outputs, 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.
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
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}
Step 5: If all goes well, you will receive a “Hello World!” JSON message.
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, which makes choosing to use Lambda URLs less compelling, even 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 introduced the option to extend the timeout beyond the usual 29s for specific use cases
