We can use Lambda@Edge functions to authorize requests that come to our CloudFront distribution. This way, the request won't proceed to the origin if it doesn't contain the required headers.
1. A scenario
Assume that our application wants to receive near-real-time notifications from a 3rd party provider. We are setting up a webhook where the partner can send the events.
Lambda function URLs are a great choice for this purpose. They are easy to set up, and an API Gateway - although it's a proper solution - would probably be overkill here.
One drawback of Lambda function URLs is that they don't allow custom domains as of writing this. A workaround to this issue is to create a CloudFront distribution where the origin is the function URL. We can then add the distribution as a target for the Route 53 record.
But we somehow should authorize the webhook requests and let only those reach the origin which contains the required token in the relevant header.
Let's see a solution to this scenario.
2. Authorization with Lambda@Edge
We can use a Lambda@Edge function to perform the authorization.
2.1. What this post doesn't contain
This article's focus is on permissions and some other minor details. It won't explain how to
- create a CloudFront distribution
- attach Lambda@Edge functions to the distribution
- create Lambda functions with a URL
- add the function URL to the distribution as the origin.
I'll have some links at the bottom of the page that describe these operations.
2.2. Some code
Let's start with the Lambda@Edge function. Since it's just a regular Lambda function that AWS distributes and deploys to the edge locations, we can have a - more or less - standard Node.js code:
const { SSM } = require('aws-sdk');
const ssm = new SSM({region: 'us-east-1'});
exports.handler = async (event) => {
const request = event.Records[0].cf.request;
const apiKeyHeader = request.headers['x-api-key'];
if (!apiKeyHeader || apiKeyHeader.length === 0) {
throw new Error('Missing token');
}
let apiKey;
try {
const ssmResponse = await ssm.getParameter({
Name: '/my/encrypted/secret',
WithDecryption: true,
}).promise();
apiKey = ssmResponse.Parameter.Value;
} catch (error) {
throw error;
}
const headerValue = apiKeyHeader[0].value;
if (headerValue !== apiKey) {
throw new Error('Invalid token');
}
return request;
};
We store the secret header value in Parameter Store. The Lambda@Edge function will get it from there and then compares it to the request header. If they match, the function will return the CloudFront request object, and it can then proceed to the origin. Otherwise, we'll throw an error if the required header is missing or the secrets don't match.
That's it, and the post could end here. But - as with CloudFront in general - some small details can make the cloud developer's life more contentful.
3. Permissions
Lambda@Edge will use the Lambda execution role we create for the function.
3.1. Assume role
We should make the role assumable for Lambda@Edge. The role's trust policy should look like this:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": [
"lambda.amazonaws.com",
"edgelambda.amazonaws.com"
]
},
"Action": "sts:AssumeRole"
}
]
}
We must add edgelambda.amazonaws.com
to the Principal
element so Lambda@Edge can assume the role.
3.2. Service permissions
In this example, the Lambda@Edge function calls Parameter Store, so we should add ssm:GetParameter
and - if the secret is in a SecureString
format - kms:Decrypt
permissions to the role.
These are not Lambda@Edge-specific permission. A regular Lambda function performing the same logic should also have these permissions.
3.3. Logging into a different region
We can think that we are good to go. We might, but there is a chance that we will receive the following error if we invoke the CloudFront distribution (or the custom domain) URL:
503 ERROR
The Lambda function associated with the CloudFront distribution
is invalid or doesn't have the required permissions. We can't
connect to the server for this app or website at this time. There
might be too much traffic or a configuration error. Try again later,
or contact the app or website owner.
The error message refers to some missing permissions.
CloudFront deploys the Lambda function to at least some edge locations different from the region where we created it. When we invoke the CloudFront URL, the Lambda@Edge closest to our geographic location will run.
In this example, I created the function in us-east-1
, but the edge location closest to me is eu-central-1
. It means the Lambda function in eu-central-1
will run and perform the authorization logic!
If it runs in eu-central-1
, it will log to this region too. The problem is that it currently doesn't have permission to do so.
Originally the CloudWatch logs permissions look like this:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "logs:CreateLogGroup",
"Resource": "arn:aws:logs:us-east-1:123456789012:*"
},
{
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": [
"arn:aws:logs:us-east-1:123456789012:log-group:/aws/lambda/FUNCTION_NAME:*"
]
}
]
}
Lambda automatically attaches this policy to the execution role when we create a function in the Console (or using some frameworks). The Resource
elements all point to the log group in us-east-1
.
Let's rectify the error. We can replace us-east-1
with a *
character in the statement:
{
"Effect": "Allow",
"Action": "logs:CreateLogGroup",
"Resource": "arn:aws:logs:*:123456789012:*"
}
Every Lambda@Edge function can now create its log groups in the corresponding region.
We should also change the Resource
for the logs:PutLogEvents
action. It is because the log group name will be /aws/lambda/us-east-1.FUNCTION_NAME
in the edge location region.
{
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": [
"arn:aws:logs:*:123456789012:log-group:/aws/lambda/FUNCTION_NAME:*",
"arn:aws:logs:*:123456789012:log-group:/aws/lambda/us-east-1.FUNCTION_NAME:*"
]
}
CloudFront won't respond with an error if we don't add the edge location version of the log group to the Resource
element. It will simply not log anything.
4. Considerations
Now that we have granted the required permissions let's look at a few potential and actual drawbacks.
4.1. Limited package size
Lambda@Edge supports a maximum of 1 MB zipped package size. So we can't really npm install
anything because we will probably exceed this limit.
Luckily, Lambda supports AWS SDK out of the box. All we have to do is require
it at the top of the index.js
handler as if it was an installed dependency.
Lambda comes with SDK v2 out of the box as of writing this. It doesn't support the modular v3 yet, but I don't think of it as a disadvantage for this example because we don't have to worry about the package size here.
Unfortunately, we can't stretch far with other dependencies, so we are forced to use language-native solutions.
4.2. No layers
Unfortunately, we can't use the new Parameter Store extension because Lambda@Edge doesn't support layers.
It's not a problem in this example because we can use the SDK that Lambda supports out of the box.
4.3. Origin request policy
If we invoke the CloudFront URL, the Lambda@Edge authorizer will run, but the logic will throw an exception because it won't receive the header with the secret API key in the request.
We should change the Origin request policy in the Behavior section to make CloudFront forward all headers to Lambda@Edge.
If we select AllViewer
here, the request payload will contain the secret header.
5. Conclusion
We can use the Lambda@Edge functions to intercept the requests in CloudFront and perform authorization.
Lambda@Edge uses the same execution role as the corresponding regular Lambda function. We must ensure that we add all regions to the log permissions and forward all headers to the authorizer function.
6. Further reading
Lambda function URLs - Start here for function URLs
Creating a distribution - How to create a CloudFront distribution
Using AWS Lambda with CloudFront Lambda@Edge - Add a Lambda function to the distribution
Using a Lambda function URL - Lambda function URL as CloudFront origin
Top comments (0)