Intermediate
Introduction: Why Lambda Function URLs Deserve Your Attention
If you’ve ever spun up an API Gateway just to put an HTTPS endpoint in front of a single Lambda function, you know the feeling — it works, but it’s overkill. You’re paying for API Gateway’s rich feature set (request validation, usage plans, caching, WAF integration) when all you really need is a URL that invokes your function.
AWS Lambda Function URLs, launched in April 2022, solve exactly this problem. They give you a dedicated HTTPS endpoint for your Lambda function — no API Gateway, no ALB, no infrastructure to manage. Combined with IAM authentication (AWS_IAM auth type), you get a secure, low-latency, cost-effective way to expose Lambda functions as HTTP endpoints.
Who should read this: Backend engineers building internal APIs, microservice-to-microservice communication layers, webhook receivers, or anyone tired of paying for API Gateway when they don’t need its full feature set. This is an intermediate-level guide — I assume you’re comfortable with Lambda, IAM policies, and the AWS CLI.
Prerequisites
- AWS CLI v2 installed and configured with appropriate credentials
- An AWS account with permissions to create Lambda functions and IAM roles/policies
- Python 3.12 runtime (used in examples, but concepts apply to any runtime)
- Basic understanding of IAM policies and AWS Signature Version 4 (SigV4)
boto3and therequestslibrary installed locally for the client examples
Lambda Function URLs vs. API Gateway: When to Use What
Before we dive in, let’s be honest about the trade-offs. Lambda Function URLs are not a universal API Gateway replacement — they’re a targeted tool for specific use cases.
| Feature | Lambda Function URL | API Gateway (REST/HTTP API) |
|---|---|---|
| Cost | No additional charge (you pay only for Lambda invocations) | $1.00–$3.50 per million requests + Lambda costs |
| Custom domain | Not natively supported (requires CloudFront) | Built-in custom domain support |
| Auth options | IAM (AWS_IAM) or None | IAM, Cognito, Lambda authorizers, API keys |
| Rate limiting/throttling | Only via Lambda reserved concurrency | Built-in throttling, usage plans, API keys |
| Request/response transformation | None — your function handles everything | Mapping templates, request validation |
| WAF integration | Not directly (requires CloudFront) | Direct WAF integration |
| Latency | Lower (no additional hop) | Slightly higher due to API Gateway processing |
| Multi-route API | Single function per URL | Multiple routes, stages, resources |
Use Lambda Function URLs when: You need a simple HTTPS trigger for a single function, you’re building service-to-service communication within AWS, you want to minimize cost and latency, or you’re receiving webhooks from third-party services.
Stick with API Gateway when: You need custom domains, multiple routes, request validation, rate limiting, Cognito auth, or WAF protection without putting CloudFront in front.
Step 1: Create the Lambda Function with a Function URL
Let’s build this from scratch. We’ll create a Lambda function that acts as a simple internal API, then secure it with IAM auth.
The Lambda Function Code
Create a file called lambda_function.py:
import json
import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def lambda_handler(event, context):
"""
Lambda Function URL handler.
The event structure for Function URLs differs from API Gateway.
See: https://docs.aws.amazon.com/lambda/latest/dg/urls-invocation.html
"""
logger.info("Received event: %s", json.dumps(event))
# Extract request details from the Function URL event format
request_context = event.get("requestContext", {})
http_info = request_context.get("http", {})
method = http_info.get("method", "UNKNOWN")
path = http_info.get("path", "/")
source_ip = http_info.get("sourceIp", "unknown")
# The caller's IAM identity is available when using AWS_IAM auth
iam_info = request_context.get("authorizer", {}).get("iam", {})
caller_arn = iam_info.get("userArn", "anonymous")
# Parse body if present
body = event.get("body", "")
is_base64 = event.get("isBase64Encoded", False)
if body and not is_base64:
try:
body = json.loads(body)
except (json.JSONDecodeError, TypeError):
pass
# Query string parameters
params = event.get("queryStringParameters", {}) or {}
# Simple routing based on path and method
if method == "GET" and path == "/health":
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"status": "healthy", "caller": caller_arn})
}
elif method == "POST" and path == "/process":
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({
"message": "Data processed successfully",
"received": body,
"caller": caller_arn
})
}
else:
return {
"statusCode": 404,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({
"error": "Not Found",
"method": method,
"path": path
})
}
Key point: The event format for Lambda Function URLs is different from both API Gateway REST APIs and HTTP APIs. It follows the Lambda Function URL payload format version 2.0, which is similar to (but not identical to) the API Gateway HTTP API payload format v2.0. Notice how the IAM caller identity is nested under requestContext.authorizer.iam — this is only populated when you use AWS_IAM auth type.
Create the Execution Role
# Create the trust policy document
cat > trust-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
EOF
# Create the IAM role
aws iam create-role \
--role-name lambda-furl-demo-role \
--assume-role-policy-document file://trust-policy.json
# Attach basic execution policy for CloudWatch Logs
aws iam attach-role-policy \
--role-name lambda-furl-demo-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
# Wait for IAM propagation
sleep 10
Deploy the Function and Create the URL
# Package the function
zip function.zip lambda_function.py
# Get your account ID
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
# Create the Lambda function
aws lambda create-function \
--function-name furl-demo-api \
--runtime python3.12 \
--handler lambda_function.lambda_handler \
--role arn:aws:iam::${ACCOUNT_ID}:role/lambda-furl-demo-role \
--zip-file fileb://function.zip \
--timeout 30 \
--memory-size 256
# Create the Function URL with IAM authentication
aws lambda create-function-url-config \
--function-name furl-demo-api \
--auth-type AWS_IAM
# Note the FunctionUrl in the output — it looks like:
# https://.lambda-url..on.aws/
That's it. No API Gateway to configure, no stage deployments, no integration setup. You now have a live HTTPS endpoint backed by your Lambda function.
Step 2: Configure IAM Permissions for Invoking the Function URL
With AWS_IAM auth type, every request must be signed with AWS Signature Version 4. The calling principal must have the lambda:InvokeFunctionUrl permission. This is a distinct permission from lambda:InvokeFunction — a common source of confusion.
Create an IAM Policy for the Caller
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGION=$(aws configure get region)
cat > invoke-furl-policy.json << EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "lambda:InvokeFunctionUrl",
"Resource": "arn:aws:lambda:${REGION}:${ACCOUNT_ID}:function:furl-demo-api",
"Condition": {
"StringEquals": {
"lambda:FunctionUrlAuthType": "AWS_IAM"
}
}
}
]
}
EOF
# Create the policy
aws iam create-policy \
--policy-name InvokeFurlDemoPolicy \
--policy-document file://invoke-furl-policy.json
# Attach to a user, role, or group as needed
aws iam attach-user-policy \
--user-name your-username \
--policy-arn arn:aws:iam::${ACCOUNT_ID}:policy/InvokeFurlDemoPolicy
Important: You also need a resource-based policy on the Lambda function itself to allow cross-account or specific principal access. For same-account access where the caller's identity-based policy grants lambda:InvokeFunctionUrl, this works. But for cross-account scenarios, you must add a resource-based policy:
# For cross-account access — add a resource-based policy
aws lambda add-permission \
--function-name furl-demo-api \
--statement-id AllowCrossAccountInvoke \
--action lambda:InvokeFunctionUrl \
--principal arn:aws:iam::123456789012:role/calling-service-role \
--function-url-auth-type AWS_IAM
Step 3: Invoke the Function URL with SigV4 Signing
This is where most people get tripped up. You can't just curl a Function URL with IAM auth — the request must be signed with SigV4. Here are three practical approaches.
Option A: Using the awscurl Tool
awscurl is a community tool that wraps curl with SigV4 signing. Install it with pip install awscurl.
# GET request
awscurl --service lambda \
--region us-east-1 \
"https://abc123xyz.lambda-url.us-east-1.on.aws/health"
# POST request with body
awscurl --service lambda \
--region us-east-1 \
-X POST \
-H "Content-Type: application/json" \
-d '{"key": "value", "action": "test"}' \
"https://abc123xyz.lambda-url.us-east-1.on.aws/process"
Critical detail: The --service parameter must be lambda, not execute-api. This is a common mistake when people are used to signing API Gateway requests.
Option B: Python Client with botocore SigV4 Signing
For service-to-service communication, here's a production-ready Python client:
import json
import requests
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
from botocore.session import Session
def invoke_function_url(url, method="GET", path="/", data=None, region="us-east-1"):
"""
Invoke a Lambda Function URL with IAM (SigV4) authentication.
"""
full_url = url.rstrip("/") + path
# Prepare headers
headers = {"Content-Type": "application/json"}
# Prepare body
body = json.dumps(data) if data else ""
# Create the AWS request object
aws_request = AWSRequest(
method=method,
url=full_url,
headers=headers,
data=body
)
# Get credentials from the default credential chain
session = Session()
credentials = session.get_credentials().get_frozen_credentials()
# Sign the request — note the service name is "lambda"
SigV4Auth(credentials, "lambda", region).add_auth(aws_request)
# Make the actual HTTP request using the signed headers
response = requests.request(
method=method,
url=full_url,
headers=dict(aws_request.headers),
data=body
)
return response
if __name__ == "__main__":
FUNCTION_URL = "https://abc123xyz.lambda-url.us-east-1.on.aws"
# GET /health
resp = invoke_function_url(FUNCTION_URL, method="GET", path="/health")
print(f"GET /health [{resp.status_code}]: {resp.text}")
# POST /process
payload = {"key": "value", "timestamp": "2024-01-15T10:30:00Z"}
resp = invoke_function_url(
FUNCTION_URL, method="POST", path="/process",
data=payload
)
print(f"POST /process [{resp.status_code}]: {resp.text}")
Option C: Calling from Another Lambda Function
When calling from another Lambda function (service-to-service), the calling function's execution role needs the lambda:InvokeFunctionUrl permission. The code is similar to Option B: