How to Use AWS Lambda Function URLs with IAM Auth as a Lightweight API Alternative to API Gateway

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)
  • boto3 and the requests library 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:


Leave a Comment

Your email address will not be published. Required fields are marked *