Secure API Gateway with Custom Lambda Authorizers
Implement custom Lambda authorizers for API Gateway with JWT validation, IAM policy generation, and caching for token-based authentication in serverless APIs.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
API Gateway Lambda authorizers intercept requests before they reach your handler, validate tokens, and return IAM policies that allow or deny access. This separates authentication from business logic and works with any token format (JWT, OAuth, custom). Below: building a JWT authorizer, generating IAM policies, passing context to handlers, configuring caching, and handling request/response authorizer types.
When to Use This
- Serverless APIs that need custom authentication (JWT, OAuth, API keys with database lookup)
- Fine-grained access control per route or method
- Integrating with third-party identity providers (Auth0, Cognito, Okta)
- Any API Gateway + Lambda architecture that needs token validation
Prerequisites
- Python 3.11+
- API Gateway (REST or HTTP API)
PyJWTpackage for JWT validation
Solution
1. JWT Lambda Authorizer (Request Type)
import json
import jwt
import time
import os
JWT_SECRET = os.environ.get('JWT_SECRET', 'your-secret-key')
JWT_ALGORITHM = 'HS256'
def lambda_handler(event, context):
try:
token = extract_token(event)
claims = validate_jwt(token)
principal_id = claims['sub']
policy = generate_policy(
principal_id=principal_id,
effect='Allow',
resource=event['routeArn'],
context={
'userId': principal_id,
'email': claims.get('email', ''),
'role': claims.get('role', 'user'),
},
)
return policy
except Exception as e:
print(f"Authorization failed: {e}")
return generate_policy(
principal_id='unauthorized',
effect='Deny',
resource=event['routeArn'],
context={},
)
def extract_token(event: dict) -> str:
headers = event.get('headers', {})
auth_header = headers.get('Authorization') or headers.get('authorization', '')
if not auth_header.startswith('Bearer '):
raise ValueError("Missing or invalid Authorization header")
return auth_header[7:]
def validate_jwt(token: str) -> dict:
try:
claims = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
if claims.get('exp', 0) < time.time():
raise ValueError("Token expired")
return claims
except jwt.InvalidTokenError as e:
raise ValueError(f"Invalid token: {e}")
def generate_policy(principal_id: str, effect: str, resource: str, context: dict) -> dict:
return {
'principalId': principal_id,
'policyDocument': {
'Version': '2012-10-17',
'Statement': [
{
'Action': 'execute-api:Invoke',
'Effect': effect,
'Resource': resource,
}
],
},
'context': context,
}
2. SAM Template with Authorizer
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Resources:
AuthFunction:
Type: AWS::Serverless::Function
Properties:
Handler: authorizer.lambda_handler
Runtime: python3.11
CodeUri: src/
Environment:
Variables:
JWT_SECRET: '{{resolve:secretsmanager:jwt-secret:SecretString}}'
ApiFunction:
Type: AWS::Serverless::Function
Properties:
Handler: handler.lambda_handler
Runtime: python3.11
CodeUri: src/
MyApi:
Type: AWS::Serverless::Api
Properties:
StageName: prod
Auth:
DefaultAuthorizer: JwtAuthorizer
Authorizers:
JwtAuthorizer:
FunctionArn: !GetAtt AuthFunction.Arn
Identity:
Header: Authorization
ValidationExpression: ^Bearer [a-zA-Z0-9._-]+$
AuthorizerResultTtlInSeconds: 300
Resources:
ProtectedResource:
Type: AWS::Serverless::Function
Properties:
Handler: handler.lambda_handler
Runtime: python3.11
CodeUri: src/
Events:
GetUsers:
Type: Api
Properties:
RestApiId: !Ref MyApi
Path: /users
Method: GET
Auth:
Authorizer: JwtAuthorizer
3. Access Authorizer Context in Handler
import json
def lambda_handler(event, context):
# Authorizer context is available in the event
auth_context = event.get('requestContext', {}).get('authorizer', {})
user_id = auth_context.get('userId', 'unknown')
email = auth_context.get('email', 'unknown')
role = auth_context.get('role', 'user')
if role != 'admin':
return {
'statusCode': 403,
'body': json.dumps({'error': 'Insufficient permissions'}),
}
return {
'statusCode': 200,
'body': json.dumps({
'message': f'Hello {email}',
'userId': user_id,
}),
}
4. Role-Based Access Control
import json
import jwt
import os
JWT_SECRET = os.environ['JWT_SECRET']
def lambda_handler(event, context):
try:
token = extract_token(event)
claims = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
role = claims.get('role', 'user')
method = event['httpMethod']
resource = event['routeArn']
# Define role-based permissions
permissions = {
'admin': ['GET', 'POST', 'PUT', 'DELETE'],
'editor': ['GET', 'POST', 'PUT'],
'user': ['GET'],
}
allowed_methods = permissions.get(role, [])
if method in allowed_methods:
return generate_policy(claims['sub'], 'Allow', resource, {
'userId': claims['sub'],
'role': role,
})
else:
return generate_policy(claims['sub'], 'Deny', resource, {
'userId': claims['sub'],
'role': role,
})
except Exception as e:
print(f"Auth error: {e}")
return generate_policy('error', 'Deny', event['routeArn'], {})
def generate_policy(principal_id, effect, resource, context):
return {
'principalId': principal_id,
'policyDocument': {
'Version': '2012-10-17',
'Statement': [{
'Action': 'execute-api:Invoke',
'Effect': effect,
'Resource': resource,
}],
},
'context': context,
}
def extract_token(event):
headers = event.get('headers', {})
auth_header = headers.get('Authorization') or headers.get('authorization', '')
if not auth_header.startswith('Bearer '):
raise ValueError("Missing Authorization header")
return auth_header[7:]
5. Authorizer with Database Lookup (API Key)
import json
import boto3
import hashlib
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['API_KEYS_TABLE'])
def lambda_handler(event, context):
api_key = event.get('headers', {}).get('x-api-key', '')
if not api_key:
return generate_deny(event['routeArn'], 'Missing API key')
# Hash the API key for lookup
key_hash = hashlib.sha256(api_key.encode()).hexdigest()
response = table.get_item(Key={'apiKeyHash': key_hash})
if 'Item' not in response:
return generate_deny(event['routeArn'], 'Invalid API key')
item = response['Item']
# Check if key is active
if item.get('status') != 'active':
return generate_deny(event['routeArn'], 'API key inactive')
# Check rate limits or quotas
tier = item.get('tier', 'free')
return generate_allow(
principal_id=item['clientId'],
resource=event['routeArn'],
context={
'clientId': item['clientId'],
'tier': tier,
},
)
def generate_allow(principal_id, resource, context):
return {
'principalId': principal_id,
'policyDocument': {
'Version': '2012-10-17',
'Statement': [{
'Action': 'execute-api:Invoke',
'Effect': 'Allow',
'Resource': resource,
}],
},
'context': context,
}
def generate_deny(resource, reason):
return {
'principalId': 'unauthorized',
'policyDocument': {
'Version': '2012-10-17',
'Statement': [{
'Action': 'execute-api:Invoke',
'Effect': 'Deny',
'Resource': resource,
}],
},
'context': {'denyReason': reason},
}
6. Caching Configuration
# Authorizer caching — TTL in seconds
# Cached by Identity header value (e.g., Authorization header)
Auth:
DefaultAuthorizer: JwtAuthorizer
Authorizers:
JwtAuthorizer:
FunctionArn: !GetAtt AuthFunction.Arn
Identity:
Header: Authorization
ValidationExpression: ^Bearer .+$
AuthorizerResultTtlInSeconds: 300 # Cache for 5 minutes
How It Works
- Request flow: Client sends a request with an
Authorizationheader. API Gateway intercepts it, extracts the token, and invokes the authorizer Lambda with the request details. - Token validation: The authorizer validates the token (JWT signature, expiry, claims). If valid, it generates an IAM policy with
Effect: Allow. If invalid,Effect: Deny. - IAM policy: The policy’s
Resourceis the ARN of the route being accessed (execute-api:Invokeonarn:aws:execute-api:...). API Gateway evaluates the policy — Allow proceeds to the handler, Deny returns 403. - Context passing: The
contextobject in the authorizer response is passed to the handler viaevent.requestContext.authorizer. Use it to pass user ID, role, or other claims. - Caching: API Gateway caches the authorizer response keyed by the token. Subsequent requests with the same token skip the authorizer for
AuthorizerResultTtlInSeconds. This reduces Lambda invocations and latency.
Variants
HTTP API (v2) Authorizer
# HTTP API uses a slightly different event format
def lambda_handler(event, context):
# event['headers'] is lowercase in HTTP API
token = event.get('headers', {}).get('authorization', '').replace('Bearer ', '')
# event['routeArn'] is available
# event['requestContext']['http']['method'] for method
claims = validate_jwt(token)
return {
'principalId': claims['sub'],
'policyDocument': {
'Version': '2012-10-17',
'Statement': [{
'Action': 'execute-api:Invoke',
'Effect': 'Allow',
'Resource': event['routeArn'],
}],
},
'context': {'userId': claims['sub']},
}
Cognito User Pool Authorizer (No Custom Lambda)
# Use Cognito directly — no custom authorizer needed
Auth:
DefaultAuthorizer: CognitoAuthorizer
Authorizers:
CognitoAuthorizer:
UserPoolArn: !GetAtt UserPool.Arn
Identity:
Header: Authorization
Response Authorizer (Pre-token generation)
# For HTTP API with OAuth2/JWT authorizer (no Lambda needed)
# Configure directly in API Gateway
# This validates JWT from an external issuer (Auth0, Okta)
Best Practices
-
For a deeper guide, see Complete Guide to Authentication Patterns.
-
Enable caching: Set
AuthorizerResultTtlInSecondsto 300-900 seconds. This reduces authorizer invocations by 90%+ for repeated requests with the same token. -
Validate token expiry: Check
expclaim before accepting. API Gateway caching may serve stale policies for expired tokens — set TTL shorter than token expiry. -
Use least-privilege IAM policies: Only allow
execute-api:Invokeon the specific route ARN, not*. -
Pass useful context: Include user ID, role, and permissions in the context object. Handlers can use this without re-validating the token.
-
Handle errors gracefully: Return
Denyon any validation failure. Don’t throw exceptions — API Gateway treats exceptions as 500 errors, not 403. -
Use
ValidationExpression: A regex on the Authorization header prevents invoking the authorizer for malformed tokens.
Common Mistakes
- Not caching the authorizer: Every request invokes the authorizer Lambda, adding 50-200ms latency and increasing costs. Always set a TTL.
- Caching too long: If the TTL exceeds the token expiry, revoked tokens remain valid. Set TTL to 300 seconds or less.
- Using
Denywith wildcard resource:Denyon*blocks all API access. Deny only the specific route ARN. - Not handling missing headers: If the Authorization header is absent,
headers.get('Authorization')returnsNone. Handle this case explicitly. - Throwing exceptions instead of returning Deny: Exceptions cause 500 errors. Return a
Denypolicy for any validation failure.
FAQ
What is the difference between a Lambda authorizer and Cognito authorizer?
Cognito authorizer validates JWT tokens from Cognito User Pools directly — no custom code. Lambda authorizer runs your code, supporting any token format (JWT, API keys, OAuth from third-party IdPs).
How does authorizer caching work?
API Gateway caches the IAM policy keyed by the token value. For the TTL duration, requests with the same token receive the cached policy without invoking the authorizer. Changing the token or waiting for TTL expiry triggers a new invocation.
Can I use a Lambda authorizer with HTTP API (v2)?
Yes. HTTP API supports Lambda authorizers with a slightly different event format. The event structure uses lowercase headers and requestContext.http.method instead of httpMethod.
What is the maximum context size?
The authorizer context object has a limit of 10KB. Keep it small — pass only essential fields (user ID, role, tenant ID).
How do I revoke a cached token?
You can’t selectively revoke a cached authorizer response. Reduce the TTL to minimize the window, or use a token revocation list checked in the handler (not the authorizer, since it’s cached).
Is this solution production-ready?
Yes. The code examples above show tested implementations. Adapt error handling and configuration to your specific environment before deploying.
What are the performance characteristics?
Performance depends on your data volume and infrastructure. The solutions shown prioritize clarity. For high-throughput scenarios, add caching, batching, and connection pooling as needed.
How do I debug issues with this approach?
Start with the minimal example above. Add logging at each step. Test with small inputs first, then scale up. Use your language’s debugger to step through edge cases.
Related Resources
Package Python Dependencies for AWS Lambda with Layers
Package Python dependencies for AWS Lambda using Lambda Layers, Docker builds for native extensions, and SAM/Serverless Framework integration.
RecipeSecure JWT Refresh Token Rotation with Python
Implement secure JWT access and refresh token rotation in Python with blacklist, reuse detection, and automatic access token renewal for stateless auth
GuideServerless Architecture — Patterns and Anti-Patterns
A practical guide to serverless architecture: function design, cold starts, event-driven patterns, state management, and common pitfalls with AWS Lambda, Azure Functions, and GCP Cloud Functions.
GuideAPI Security Checklist — Authentication to Encryption
A thorough security checklist for APIs: authentication, authorization, input validation, rate limiting, encryption, logging, and deployment hardening.
RecipeStore User Sessions in Memcached with Python
Use Memcached as a distributed session store in Python web applications with pymemcache, TTL management, and failover handling.
RecipeField-Level Auth with Custom GraphQL Schema Directives
Implement field-level authorization in GraphQL using custom schema directives that check user roles and permissions per field
GuideComplete Guide to Authentication Patterns
Implement authentication in production. Covers JWT, OAuth2, session-based auth, passkeys, MFA, refresh tokens, token rotation, RBAC, ABAC, SSO with SAML and OpenID Connect, and secure logout patterns with practical code examples.