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

FailureSymptomFix
Narrow resource ARN in policy documentCached policy only allows the first route the caller hit; subsequent routes return 403 even for valid tokensUse 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 policyThrowing 'Unauthorized' returns 401; returning a Deny policy returns 403 — clients may not distinguish these correctlyThrow '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 arrayAuthorizer invocation fails; API Gateway returns 500 to the callerContext 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 timeoutAuthorizer times out before validating; API Gateway returns 504 Gateway TimeoutSet 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 GatewayAll requests return 500; CloudWatch shows AccessDeniedException in API Gateway logsAdd 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 revocationRevoked tokens continue to work for up to TTL secondsFor 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 documentAuthorization decision is ignored; all requests pass throughHTTP 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