The fastest way to expose AWS Lambda to Internet via Function URL

From what I can see, Serverless applications, especially ones heavily utilizing AWS Lambda, are getting increasingly popular nowadays. Lambda is an excellent tool for almost any project size when developers do not want to do any computing planning and want to focus on writing code. It is handy: you create your function, and it "magically" appears in the Cloud.

One of the "limitations" lambdas have is that they were not intended to be used for REST API development. The usual solution to this problem is bringing in Amazon API Gateway in front of your functions to handle REST. This approach works very well for any project size: from a startup with one API to an enterprise with thousands of APIs. But sometimes it feels like an overkill. For example, you develop a "micro" project with one or two lambdas that you want to use. For this use case having an API Gateway feels too much from an operations and cost perspective.

Fortunately, AWS Lambdas have a feature known as Function URLs. When you enable this feature, Amazon will create a dedicated HTTPS endpoint for your function. Function URL format follows the same pattern:

https://{url-id}.lambda-url.{region}.on.aws/

I will create a lambda function and expose it to the public endpoint in the following example. The lambda function accepts one parameter name and returns a JSON object with the message "Hello, {name}!"

from typing import Dict, Any
import json


def error(message: str) -> Dict[str, Any]:
    body = json.dumps({"error": message})
    return {"statusCode": 400, "body": body}


def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
    req = event.get("queryStringParameters", None)
    if req is None:
        return {"statusCode": 400, "body": error("Bad Request")}

    name = req.get("name", "").strip()
    if name == "":
        return {"statusCode": 400, "body": error("Name is required")}

    res = {"message": f"Hello, {name}!"}
    return {"statusCode": 200, "body": json.dumps(res)}

The function's name is my_public_hello_world. I will use this name in the scripts below.

As you may note, the function expected a particular event structure. The event format is well defined in AWS Lambda's Function URLs documentation. I recommend taking a look if you haven't done it yet.

The lambda function is ready, and it is time to make it publicly available. As everything is AWS, there are multiple ways of doing it (AWS Console, AWS CLI, CloudFormaton, etc.) I prefer command line configuration, so let's use the AWS CLI.

aws lambda create-function-url-config --function-name my_public_hello_world --auth-type NONE

You might be curious what --auth-type NONE means. Function URLs support two security models:

  • NONE. This authentication model means the lambda does not provide any security check, but it will require a lambda's resource policy granting lambda:InvokeFunctionUrl permission to "*" (all users).
  • AWS_IAM. This model tells lambda to use IAM to authenticate users. So, as a developer, you will create users or roles in IAM and grant them access to call the lambda over the Internet. I show its sample below after we sort out the NONE authentication model example.

Your function's output should be similar to the following JSON.

{
  "FunctionUrl": "https://b65ugjirvcpyl6rfyyhru63x7q0ztjha.lambda-url.us-east-1.on.aws/",
  "FunctionArn": "arn:aws:lambda:us-east-1:XXXXXXXXXXXX:function:my_public_hello_world",
  "AuthType": "NONE",
  "CreationTime": "2022-10-20T15:53:23.180321Z"
}

The most important information here is the FunctionUrl attribute that tells use the endpoint URL to call the lambda function. Let's give it a try using CURL.

$ curl https://b65ugjirvcpyl6rfyyhru63x7q0ztjha.lambda-url.us-east-1.on.aws
{"Message":"Forbidden"}

The problem here is my sample lambda function does not have a resource policy that allows anyone to execute it. Let's add it.

aws lambda add-permission --function-name my_public_hello_world --action lambda:InvokeFunctionUrl --statement-id https --principal "*" --function-url-auth-type NONE --output text

Now our function is open to all Internet. Please make sure you are opening up lambdas very carefully. Anyone can access it as long as she knows the URL. It is your responsibility to develop customer authentication and authorization, e.g., validate the captcha if you develop an open API, or secure your endpoint with JWT tokens, etc.

$ curl "https://b65ugjirvcpyl6rfyyhru63x7q0ztjha.lambda-url.us-east-1.on.aws/?name=John"
{"message": "Hello, John!"}

Custom security mechanisms development is not an easy task, and developers want to avoid it if possible. AWS Lambda's Function URL supports IAM-based authentication, which may simplify your life. If you decide to use this mode, your app's users or microservices will be authenticated via IAM to call lambda. You will be able to use the full power of AWS IAM to allow or deny access to your endpoints.

First, let's remove the old endpoint.

aws lambda delete-function-url-config --function-name my_public_hello_world

And create a new one based on IAM.

aws lambda create-function-url-config --function-name my_public_hello_world --auth-type AWS_IAM

If everything works well, you should see a similar output:

{
  "FunctionUrl": "https://gpjrjlfev5cwef6ydaetd2gx2m0stsln.lambda-url.us-east-1.on.aws/",
  "FunctionArn": "arn:aws:lambda:us-east-1:XXXXXXXXXXXX:function:my_public_hello_world",
  "AuthType": "AWS_IAM",
  "CreationTime": "2022-10-20T16:58:39.807565Z"
}

As you may expect, the CURL request to the endpoint fails.

$ curl "https://gpjrjlfev5cwef6ydaetd2gx2m0stsln.lambda-url.us-east-1.on.aws/?name=Johm"
{"Message":"Forbidden"}

The first step to solving this problem is making the HTTP request via a tool that supports AWS Signature Version 4 (SigV4). I use awscurl, which is available on Brew if you use macOS.

Second, you need to define either an identity-based policy or a resource-based policy for the lambda. In my example, I will attach an identity-based policy to my test user.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "https",
      "Effect": "Allow",
      "Action": "lambda:InvokeFunctionUrl",
      "Resource": "arn:aws:lambda:us-east-1:XXXXXXXXXXXX:function:my_public_hello_world",
      "Condition": {
        "StringEquals": {
          "lambda:FunctionUrlAuthType": "AWS_IAM"
        }
      }
    }
  ]
}

The Condition attribute IS VERY IMPORTANT. Your user won't be able to call the function if it has not been provided.

Once the correct policy is in place, my test user is capable of calling the URL using awscurl utility.

$ awscurl --service lambda "https://gpjrjlfev5cwef6ydaetd2gx2m0stsln.lambda-url.us-east-1.on.aws/?name=John"
{"message": "Hello, John!"}

Last but not least is mentioning Function URLs support CORS, which is a crucial feature when you develop web applications. Please follow Function URLs CORS article in AWS documentation.

Finally, I want to share my opinion on when you will need this feature. Each function has its unique endpoint, so the more lambdas you have, the more endpoints your client application will have to consume. That's why I wrote at the beginning that this approach works well for "micro" applications, e.g., you need a backend for the contact form for your blog, as I have on mine. Another use case is a microservices architecture when each function is a microservice. In this case, Function URL offers a cheaper solution (otherwise, you need an API gateway per function). For more sophisticated use cases, like when you have to expose multiple lambdas within the same project, consider Amazon API Gateway.

All sample code is available on my GitHub repository