Securing Serverless APIs: A Guide to Lambda Authorizers

Learn how to secure your Amazon API Gateway endpoints using AWS Lambda authorizers, with a focus on validating JWTs (JSON Web Tokens) for robust, token-based authentication.

When you build a public-facing API with Amazon API Gateway and AWS Lambda, one of the first questions you must answer is: "How do I control who can access it?" While API keys offer basic protection, a much more robust and standard approach is to use token-based authentication with JSON Web Tokens (JWTs).

API Gateway's Lambda Authorizers (formerly known as Custom Authorizers) are the perfect tool for this job. They are Lambda functions that you write to implement your own custom authorization logic.

How Do Lambda Authorizers Work?

The flow is simple but powerful:

  1. A client makes a request to your API Gateway endpoint, including an Authorization header (e.g., Bearer eyJhbGciOi...).
  2. API Gateway automatically invokes your Lambda Authorizer function before forwarding the request to your backend integration (e.g., your main Lambda function).
  3. It passes the entire Authorization token to the authorizer function.
  4. Your authorizer function's job is to validate the token. If the token is valid, the function returns an IAM policy document that either allows or denies the request.
  5. If allowed, API Gateway proceeds to invoke your backend function. If denied, it returns a 403 Forbidden error to the client.

Lambda Authorizer Flow

Building a JWT Lambda Authorizer

Let's build an authorizer in Python that validates a JWT signed with the RS256 algorithm, a common standard used by identity providers like Auth0 or Amazon Cognito.

Prerequisites:

  • An identity provider (like Cognito) that issues JWTs.
  • The public key (JWKS - JSON Web Key Set) URL of your identity provider.

The Authorizer Code:

import os
import jwt
import requests
from functools import lru_cache

# Identity Provider's JWKS URL - store this in an environment variable
JWKS_URL = os.environ.get('JWKS_URL')

@lru_cache(maxsize=1)
def get_jwks():
    """Fetches the JWKS and caches it."""
    return requests.get(JWKS_URL).json()

def get_public_key(token):
    """Finds the appropriate public key from the JWKS to verify the token."""
    try:
        unverified_header = jwt.get_unverified_header(token)
    except jwt.exceptions.DecodeError:
        raise ValueError('Invalid token header')

    jwks = get_jwks()
    for key in jwks['keys']:
        if key['kid'] == unverified_header['kid']:
            return {
                'kty': key['kty'],
                'kid': key['kid'],
                'use': key['use'],
                'n': key['n'],
                'e': key['e']
            }
    raise ValueError('Public key not found')

def handler(event, context):
    """The Lambda Authorizer main handler."""
    try:
        token = event['authorizationToken'].split(' ')[1] # Extract token from 'Bearer <token>'
        public_key = get_public_key(token)

        # Decode and verify the token
        decoded = jwt.decode(
            token,
            public_key,
            algorithms=['RS256'],
            audience='my-api-audience', # The audience for your API
            issuer='https://my-idp.com/' # The issuer from your IDP
        )

        # If verification is successful, return an 'Allow' policy
        policy = generate_policy(decoded['sub'], 'Allow', event['methodArn'])
        # You can also pass context to the backend Lambda
        policy['context'] = {
            'userId': decoded['sub'],
            'scope': decoded.get('scope', '')
        }
        return policy

    except (ValueError, jwt.PyJWTError) as e:
        print(f"Auth error: {e}")
        # For security, don't return detailed error messages to the client
        # API Gateway will automatically return a 403 Forbidden
        return generate_policy('user', 'Deny', event['methodArn'])

def generate_policy(principal_id, effect, resource):
    """Generates the required IAM policy document."""
    return {
        'principalId': principal_id,
        'policyDocument': {
            'Version': '2012-10-17',
            'Statement': [
                {
                    'Action': 'execute-api:Invoke',
                    'Effect': effect,
                    'Resource': resource,
                }
            ],
        },
    }

Key Concepts in the Code

  • JWKS (JSON Web Key Set): This is a standard way for an identity provider to publish its public keys. Our authorizer fetches this set to find the correct key to verify the token's signature.
  • Caching (@lru_cache): Fetching the JWKS is an I/O operation. Caching it in memory significantly improves the performance of subsequent authorizer invocations.
  • Token Validation: We're not just checking the signature. We are also validating the audience (is this token intended for our API?) and the issuer (did it come from the identity provider we trust?).
  • Policy Generation: The final output must be an IAM policy. This is how you tell API Gateway whether the request is authorized.
  • Passing Context: The context block in the returned policy is a powerful feature. Any key-value pairs you add here will be passed directly to your backend Lambda function in the event.requestContext.authorizer object. This is perfect for passing the user's ID or permissions without needing to decode the JWT again in your business logic.

Conclusion

Lambda Authorizers are a flexible and powerful way to implement custom security logic for your serverless APIs. By leveraging them to validate JWTs, you can build secure, scalable, and standards-compliant authentication systems that integrate seamlessly with any OpenID Connect (OIDC) compliant identity provider.