Guide · AWS API Gateway
MCP Server API Gateway Lambda Authorizer — TOKEN type, REQUEST type, caching, and context variables
A Lambda authorizer is a Lambda function that API Gateway invokes before routing a request to the backend integration — it returns an IAM policy document that either allows or denies the request, plus an optional context object whose key-value pairs are forwarded to the downstream Lambda as $context.authorizer.* variables. For MCP tool endpoints, Lambda authorizers are the right choice when: authentication requires more than JWT validation (e.g., checking a database for revoked tokens, validating a custom API key format, or combining API key + JWT); the auth logic is shared across multiple APIs; or the auth response needs to carry claims into the integration Lambda. API Gateway caches the authorizer result by a configurable TTL and cache key (the identity source expression) — a correctly configured cache eliminates cold-start overhead on every request after the first call per token.
TL;DR
Use TOKEN type for simple bearer token scenarios — it receives only the token string, caches by token value, and has slightly lower invocation overhead. Use REQUEST type for anything else — it receives the full request (headers, query params, stage variables) and caches by any combination of these values. The IAM policy resource ARN in the response should be broad (e.g., arn:aws:execute-api:{region}:{account}:{api-id}/{stage}/*/*) so the cached policy covers all routes, not just the one that triggered the authorizer. Set authorizerResultTtlInSeconds to 300 (5 minutes) for most tokens — setting it to 0 disables caching and adds authorizer latency to every request. Never log the raw token in the authorizer Lambda — log the extracted principalId instead.
TOKEN type authorizer: bearer token validation
The TOKEN type is the simpler of the two authorizer types. API Gateway extracts the token from the header specified by identitySource (typically method.request.header.Authorization) and passes only that string to the Lambda — no other request details. This makes TOKEN authorizers faster and easier to cache: the cache key is always the token value itself.
// TOKEN type Lambda authorizer — receives event.authorizationToken
// Returns an IAM policy document allowing or denying the request
export const handler = async (event) => {
// event.authorizationToken is the raw header value, e.g. "Bearer eyJ..."
const token = event.authorizationToken;
// event.methodArn is the ARN of the requested route:
// arn:aws:execute-api:us-east-1:123:abc123def/prod/POST/tools/invoke
// Remove "Bearer " prefix if present
const jwt = token?.startsWith('Bearer ') ? token.slice(7) : token;
let claims;
try {
claims = await verifyJwt(jwt);
} catch (err) {
// Throwing with message "Unauthorized" causes API Gateway to return 401
// Any other throw message causes 500
throw new Error('Unauthorized');
}
// Build policy that allows the caller's principal to invoke all routes in this API
// Using a wildcard resource allows the cached policy to work for all subsequent routes
// A narrow ARN (just this route) means every new route the caller hits = new authorizer call
const apiArnParts = event.methodArn.split('/');
const wildcardArn = `${apiArnParts[0]}/${apiArnParts[1]}/*/*`;
return {
principalId: claims.sub, // required — identifies the caller in logs
policyDocument: {
Version: '2012-10-17',
Statement: [{
Action: 'execute-api:Invoke',
Effect: claims ? 'Allow' : 'Deny',
Resource: wildcardArn
}]
},
// context: key-value pairs forwarded to the integration Lambda as $context.authorizer.*
// Values must be strings, numbers, or booleans — not objects or arrays
context: {
userId: claims.sub,
email: claims.email ?? '',
plan: claims['custom:plan'] ?? 'free',
// Serialize complex data as JSON strings
scopes: JSON.stringify(claims.scope?.split(' ') ?? [])
},
// Override the TTL for this specific response (optional)
// Useful for short-lived tokens — set ttl to token's remaining lifetime
// usageIdentifierKey: apiKeyValue // only for API key usage plans (REST API)
};
};
REQUEST type authorizer: multi-source authentication
The REQUEST type authorizer receives the full request context — headers, query parameters, path parameters, stage variables, and the request context. It is required for HTTP API Lambda authorizers (HTTP API has no TOKEN type) and for REST API scenarios where authentication involves more than a single header value (e.g., an API key header + a JWT bearer token must both be valid). The cache key for REQUEST type is an expression composed of one or more identity sources — the authorizer is only called once per unique combination of identity source values, then the result is cached.
// REQUEST type Lambda authorizer — full request access
export const requestAuthorizerHandler = async (event) => {
// Headers (lowercase keys in REST API; may be mixed case in HTTP API)
const authorization = event.headers?.Authorization
?? event.headers?.authorization;
const apiKey = event.headers?.['X-Api-Key']
?? event.headers?.['x-api-key'];
// Query parameters
const sessionId = event.queryStringParameters?.sessionId;
// Stage variables (set in API Gateway stage config — not user-controlled)
const environment = event.stageVariables?.env ?? 'prod';
if (!authorization || !apiKey) {
throw new Error('Unauthorized');
}
// Validate API key against database
const keyRecord = await validateApiKey(apiKey);
if (!keyRecord) throw new Error('Unauthorized');
// Validate JWT bearer token
const jwt = authorization.replace('Bearer ', '');
const claims = await verifyJwt(jwt);
if (!claims) throw new Error('Unauthorized');
// Verify the JWT sub matches the API key owner
if (claims.sub !== keyRecord.userId) throw new Error('Unauthorized');
const wildcardArn = buildWildcardArn(event.methodArn);
return {
principalId: claims.sub,
policyDocument: {
Version: '2012-10-17',
Statement: [{
Action: 'execute-api:Invoke',
Effect: 'Allow',
Resource: wildcardArn
}]
},
context: {
userId: claims.sub,
apiKeyId: keyRecord.id,
plan: keyRecord.plan,
rateLimit: String(keyRecord.rateLimit) // must be string, not number
}
};
};
// Identity source for caching: "method.request.header.Authorization,method.request.header.X-Api-Key"
// The authorizer is called once per unique (Authorization, X-Api-Key) pair
// If either value changes, the authorizer is invoked again
function buildWildcardArn(methodArn) {
// methodArn: arn:aws:execute-api:region:account:apiId/stage/METHOD/resource/path
// Wildcard: arn:aws:execute-api:region:account:apiId/stage/*/*
const parts = methodArn.split(':');
const apiParts = parts[5].split('/');
return `${parts.slice(0, 5).join(':')}:${apiParts[0]}/${apiParts[1]}/*/*`;
}
Context variables: passing auth claims to integration Lambdas
Context values set in the authorizer response are available in the integration Lambda as event.requestContext.authorizer (REST API) or event.requestContext.authorizer.lambda (HTTP API with Lambda authorizer). Context values can only be strings, numbers, or booleans — not objects, arrays, or null. To pass complex data like scopes or roles, serialize as a JSON string and deserialize in the integration Lambda.
// Integration Lambda receiving authorizer context (REST API)
export const toolInvokeHandler = async (event) => {
// REST API: authorizer context at event.requestContext.authorizer
const authorizer = event.requestContext.authorizer;
const userId = authorizer.userId; // string
const plan = authorizer.plan; // string ("free", "author", "team")
const rateLimit = Number(authorizer.rateLimit); // was string — convert back to number
const scopes = JSON.parse(authorizer.scopes ?? '[]'); // was JSON string
// Check plan-based access control
if (plan === 'free' && isPrivateEndpoint(event.pathParameters.toolName)) {
return {
statusCode: 403,
body: JSON.stringify({ error: 'Private endpoints require Author or Team plan' })
};
}
// ... tool invocation logic ...
};
// HTTP API: authorizer context at event.requestContext.authorizer.lambda
export const httpApiToolHandler = async (event) => {
// HTTP API with Lambda authorizer (simple response format):
// The context object is at event.requestContext.authorizer.lambda
const userId = event.requestContext.authorizer?.lambda?.userId;
// HTTP API with JWT authorizer (native, no Lambda):
// Claims are at event.requestContext.authorizer.jwt.claims
const jwtUserId = event.requestContext.authorizer?.jwt?.claims?.sub;
};
Cache configuration and TTL tuning
The authorizer cache prevents invoking the Lambda on every request. The authorizerResultTtlInSeconds setting (0–3600) controls how long a result is cached for a given identity source value. Setting it to 0 disables caching — every request invokes the Lambda, adding authorizer latency and cold-start risk. The optimal TTL balances freshness against Lambda invocations: for long-lived JWTs (1-hour expiry), use a TTL of 300 seconds; for short-lived tokens (5-minute expiry), use a TTL of 60–120 seconds; for API keys that are rarely revoked, use 600 seconds.
// CloudFormation — REST API Lambda Authorizer with caching
Resources:
ToolsAuthorizer:
Type: AWS::ApiGateway::Authorizer
Properties:
Name: tools-jwt-authorizer
RestApiId: !Ref ToolsApi
Type: TOKEN # TOKEN or REQUEST
AuthorizerUri: !Sub
arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${AuthorizerFunction.Arn}/invocations
IdentitySource: method.request.header.Authorization # cache key
AuthorizerResultTtlInSeconds: 300 # cache for 5 minutes
# For REQUEST type, IdentitySource can be a comma-separated list:
# "method.request.header.Authorization,method.request.header.X-Api-Key"
# Lambda invocation permission — API Gateway must be allowed to call the authorizer
AuthorizerInvokePermission:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !Ref AuthorizerFunction
Action: lambda:InvokeFunction
Principal: apigateway.amazonaws.com
# SourceArn scopes permission to this API's authorizers only
SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ToolsApi}/authorizers/*
# IMPORTANT: The cached policy is associated with the principalId + identity source combination.
# If you change the principalId between calls with the same token, the cached policy
# from the first call is returned — the Lambda is NOT re-invoked.
# Use stable, unique principalIds (JWT sub claim) — never use a non-unique value like "user".
Failure modes reference
| Failure | Symptom | Fix |
|---|---|---|
| Narrow resource ARN in policy document | Cached policy only allows the first route the caller hit; subsequent routes return 403 even for valid tokens | Use wildcard resource: arn:aws:execute-api:region:account:apiId/stage/*/* — the cached policy then covers all method/path combinations for that stage |
| Throw new Error('Unauthorized') vs return Deny policy | Throwing 'Unauthorized' returns 401; returning a Deny policy returns 403 — clients may not distinguish these correctly | Throw 'Unauthorized' for missing/malformed tokens (401 = not authenticated); return a Deny policy for valid tokens with insufficient permissions (403 = authenticated but not authorized) |
| Context value is an object or array | Authorizer invocation fails; API Gateway returns 500 to the caller | Context values must be primitives (string, number, boolean); serialize complex values with JSON.stringify() and deserialize in the integration Lambda |
| Authorizer Lambda timeout shorter than integration timeout | Authorizer times out before validating; API Gateway returns 504 Gateway Timeout | Set authorizer Lambda timeout to at least 5 seconds; recommended 10 seconds for authorizers that make network calls (JWT JWKS fetch, database lookup) |
| Lambda:InvokeFunction permission not granted to API Gateway | All requests return 500; CloudWatch shows AccessDeniedException in API Gateway logs | Add aws_lambda_permission allowing lambda:InvokeFunction with principal apigateway.amazonaws.com; scope SourceArn to the specific API's authorizer ARN |
| Cache not invalidating on token revocation | Revoked tokens continue to work for up to TTL seconds | For immediate revocation, set TTL to 0 (no cache) or implement token revocation via a blocklist check inside the authorizer — check the blocklist before verifying the JWT; a DynamoDB GetItem for the token ID adds ~5 ms but enables instant revocation |
| HTTP API Lambda authorizer returns IAM policy document | Authorization decision is ignored; all requests pass through | HTTP API Lambda authorizers default to simple response format: return { isAuthorized: true/false }; to use IAM policy format, set authorizerPayloadFormatVersion to "1.0" on the authorizer configuration |