Jimmy Wei

Optimizing Lambda Function URLs - Cost-Efficient and Secure Strategies

23 May
AWS, Lambda, Function URLs, Cost, Security

Introduction to Lambda Function URLs

Lambda Function URLs landed last year in April 2022. In many ways they handle what already existed in the AWS ecosystem; exposing a lambda function to the public internet prior to this was a case of having a public API gateway. Authorisation could be handled at the API gateway level and you were on your way. So what exact problem does Lambda Function URLs solve?

The caveat associated with bringing up an API Gateway itself just for your lamda function is an extra overhead, particularly if we only have a handful of lambda functions we wanted to expose.

That overhead can be observed through managing that extra piece of infrastructure, but also overhead in terms of additional costs- particularly cost per API call through the API gateway can add up.

Cost Comparison: 5 million requests 🔗

Assume 5 million requests go through at 128MB assigned memory in US East (N. Virginia), with an average invocation of 50ms, excluding data egress costs:

API Gateway + Lambda

Lambda: 5 million requests, 128MB memory assigned at 50ms = $1.53/month
API Gateway: $3.50/million * 5 million = $17.50/month

Total Costs: $19.03/month

Lambda Function URLs

Lambda: 5 million requests, 128MB memory assigned at 50ms = $1.53/month

Total Costs: $1.53/month

That represents 12x cost increase associated with the requests alone which isn’t cost efficient. Of course, the more requests you have going through, the cheaper it gets both for the API gateway and Lambda but for the majority of use cases where Lambda Function URLs are applicable for, it seems unreasonably expensive.

Lambda Function URLs come with tradeoffs in comparison to Lambda + API Gateway, such tradeoffs do include being unable to rate limit users based on their identity with a function URL- and while it is possible to throttle the lambda function, this would impact all consumers of the Lambda function. However, if you don’t actually need these extra features, then that should be part of the justification for opting in for for Lambda Function Urls- that being said, there are all round good use cases for when to use and not use them.

URL Anatomy and The Public Internet 🔗

When a Lambda Function URL gets deployed, you get a URL that is associated with that Lambda function.

It looks like this: https://<RANDOM-ID>.lambda-url.<REGION>.on.aws, where the random ID is determined on the initial deployment, and REGION is the region in which the function is deployed in. All deployed URLS will also be deployed using https - this is something that’s not configurable.

serverless.yml - Using Serverless Framework

service: lambda-urls-demo

provider:
  name: aws
  stage: dev
  region: eu-west-2

functions:
  test:
    handler: handler.demoHandler
    url: true
/*
  handler.js
 */
function demoHandler(event) {
	return {
		statusCode: 200
    }
}

module.exports = {
	demoHandler
}

Running: serverless deploy --aws-profile PROFILE

Deploying lambda-urls-demo to stage dev (eu-west-2)

✔ Service deployed to stack lambda-urls-demo-dev (34s)

endpoint: https://v2olvkdtht02sy5utbvzshbcei0szimt.lambda-url.eu-west-2.on.aws/
functions:
  test: lambda-urls-demo-dev-test (1.9 kB)

This URL doesn’t change unless you destroy and recreate the entire lambda function, but as the entry point is a URL, that means that Lambda Function URLs by design are accessible on the public internet.

There isn’t any way to associate the Lambda Function URL to a private VPC either, traffic will always cross the public internet by design. If this is a deal breaker, then naturally Lambda Function URLs aren’t the correct choice for your architecture.

Securing Lambda Function URL 🔗

Lambda Function URLs natively allow you to set the authentication type, this can either be configured as AWS_IAM or NONE.

AWS_IAM is a good default choice, as it allows delegating authorisation to the AWS IAM service - however there are caveats with this, the main one being how one actually calls the Lambda Function URL when this is set.

service: lambda-urls-demo

provider:
  name: aws
  stage: dev
  region: eu-west-2

functions:
  test:
    handler: handler.demoHandler
    url:
      authorizer: aws_iam # Enables AWS_IAM authorization

With AWS_IAM enabled, this requires the request made to be pre-signed before sending out the request.

The simplest way to verify that it works without worrying about how to implement signature generations is via docker using a tool called awscurl.

docker run --rm -it okigan/awscurl 
  --access_key ACCESS_KEY 
  --secret_key SECRET_KEY 
  --region REGION 
  --service lambda 
  www.12345.lambda-url.eu-west-2.on.aws

Requests made to the Lambda Function URL has to be pre-signed and sent across in the headers. AWS then regenerates the signature upon receiving the request given the request and payload. The signatures have to both match in order for the request to be allowed.

Once we’ve verified that the endpoint is communicable and secured via AWS_IAM, we can look further to integrating it into our own applications.

'Firewalling' your Lambda Function URL 🔗

Lambda Function URLs have particular properties in the event such as the user’s IP address, which we can use this to further enhance the security of our lambda function.

/*
  handler.js
 */
function demoHandler(event) {
	const clientIpAddress = event["headers"]["x-forwarded-for"];

	const whitelistedIps = [
		"133.33.33.7"
	];
	if (!whitelistedIps.includes(clientIpAddress)) {
		return {
			statusCode: 401
		}
	}

	return {
		statusCode: 200
	}
}

module.exports = {
	demoHandler
}

This works with AWS_IAM authorization but perhaps offers more value for the NONE authentication type.

We can observe the event’s x-forwarded-ip header which will reveal the requestor’s public IP address. If our the whitelisted IP doesn’t match, we can reject the request within the application code.

This still counts as a lambda invocation however because the function sits on the public internet, so any authenticated calls made to it will result in an invocation.

This may be a suitable compromised if the consumer doesn’t have an IAM user, but at the overhead of managing whitelisted IP addresses. We could of course take this a step further by involving a stateful database to manage these IPs such as DynamoDB.

Retrieving the AWS_IAM user identity 🔗

Given the AWS_IAM authentication method, we can also retrieve within the event payload who made the request.

This may be useful for auditing purposes or for other reasons downstream in the application that requires the identity of the caller.

function demoHandler(event) {
	const userArn = event["requestContext"]["authorizer"]["iam"]["userArn"];
	console.log(userArn); // arn:aws:iam::XXXXXXXXXXXX:USER

	console.log(JSON.stringify(event["requestContext"]));
	/*
        "accountId": "390901917839",
        "apiId": "v2olvkdtht72sy4utbvvshbcwi0szimt",
        "authorizer": {
            "iam": {
                "accessKey": "XYZ",
                "accountId": "XXXXXXXXXXXX",
                "callerId": "XXXXXXXXXXXX",
                "cognitoIdentity": null,
                "principalOrgId": "o-1YYYYYYYY",
                "userArn": "arn:aws:iam::XXXXXXXXXXXX:USER",
                "userId": "XXXXXXXXXXXX"
            }
        },
        "domainName": "v2olvkdtht02sy5utbvzshbcei0szimt.lambda-url.eu-west-2.on.aws",
        "domainPrefix": "v2olvkdtht02sy5utbvzshbcei0szimt",
        "http": {
            "method": "GET",
            "path": "/",
            "protocol": "HTTP/1.1",
            "sourceIp": "185.241.227.234",
            "userAgent": "python-requests/2.30.0"
        },
        "requestId": "89381aa6-0d46-4cd5-8b2e-8f7c7f6c223b",
        "routeKey": "$default",
        "stage": "$default",
        "time": "23/May/2023:20:29:39 +0000",
        "timeEpoch": 1684873779193
     */

	return {
		statusCode: 200
	}
}

module.exports = {
	demoHandler
}

Conclusion 🔗

Lambda Function URLs offers an opportunity of major cost savings and the ability to get started as soon as possible. It's never been faster to deploy out a piece of business logic to the public internet before, but with this comes with the right use cases.

Webhooks are without a doubt the best candidate for this, they are typically event driven and applications typically authenticate through access tokens or API Keys. API Gateway can also offer this natively, but at the overhead cost of the managing the API Gateway is overkill.

Single function microservices that permit transiting data over the public internet may also be the right choice here. Any microservice that has multiple functions involved would result in multiple URLs to manage, which may add extra overhead in the long run, so is not advisable. The API Gateway abstracts this through stages under the same rest API ID, i.e www.<rest-id>.apigateway.aws.com/<stage>/url-1.

Furthermore, sensitive data that should flow within a private VPC would not be suitable here- a private API gateway would be more appropriate through configuring the lambda function to be attached to the private VPC.