r directly to them.
import * as cdk from 'aws-cdk-lib';
import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2';
import * as apigwv2Integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import * as lambda from 'aws-cdk-lib/aws-lambda';
export class SecureApiStack extends cdk.Stack {
constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const authorizerFn = new lambda.Function(this, 'TokenValidator', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'authorizer.handler',
code: lambda.Code.fromAsset('lambda/auth'),
timeout: cdk.Duration.seconds(5),
});
const httpApi = new apigwv2.HttpApi(this, 'PaymentApi', {
defaultAuthorizer: new apigwv2.HttpLambdaAuthorizer(
'StrictAuthorizer',
authorizerFn,
{ authorizerResultTtl: cdk.Duration.seconds(0) }
),
defaultPayloadFormatVersion: apigwv2.PayloadFormatVersion.VERSION_2_0,
});
// Explicit route binding with authorizer
httpApi.addRoutes({
path: '/v1/transactions',
methods: [apigwv2.HttpMethod.POST],
authorizer: new apigwv2.HttpLambdaAuthorizer(
'TransactionAuth',
authorizerFn,
{ authorizerResultTtl: cdk.Duration.seconds(0) }
),
integration: new apigwv2Integrations.HttpUrlIntegration(
'TransactionBackend',
'https://internal-api.example.com/v1/transactions'
),
});
// Fallback route without auth (public health check)
httpApi.addRoutes({
path: '/health',
methods: [apigwv2.HttpMethod.GET],
integration: new apigwv2Integrations.HttpUrlIntegration(
'HealthBackend',
'https://internal-api.example.com/health'
),
});
}
}
Rationale: Binding the authorizer explicitly to /v1/transactions ensures the gateway evaluates authentication before routing. Setting authorizerResultTtl to 0 prevents stale token caching from masking path normalization issues during testing.
Step 2: Implement Path Canonicalization in the Authorizer
The authorizer must normalize the incoming path before evaluating policies. This neutralizes trailing slashes, double slashes, and URL-encoded variants.
import { APIGatewayProxyAuthorizerHandler, APIGatewayTokenAuthorizerResult } from 'aws-lambda';
const normalizeRequestPath = (rawPath: string): string => {
// Remove trailing slashes, collapse multiple slashes, decode percent-encoded characters
const decoded = decodeURIComponent(rawPath);
const collapsed = decoded.replace(/\/{2,}/g, '/');
const trimmed = collapsed.replace(/\/+$/, '');
return trimmed || '/';
};
export const handler: APIGatewayProxyAuthorizerHandler = async (event): Promise<APIGatewayTokenAuthorizerResult> => {
const rawPath = event.routeKey?.split(' ')[1] || event.requestContext?.http?.path || '';
const canonicalPath = normalizeRequestPath(rawPath);
// Policy evaluation against canonical path
const allowedPaths = ['/v1/transactions', '/v1/balances', '/v1/accounts'];
const isAuthorized = allowedPaths.includes(canonicalPath);
if (!isAuthorized) {
return {
principalId: 'anonymous',
policyDocument: {
Version: '2012-10-17',
Statement: [{ Action: 'execute-api:Invoke', Effect: 'Deny', Resource: event.methodArn }],
},
};
}
// Token validation logic would execute here
const token = event.authorizationToken?.replace('Bearer ', '');
if (!token || token.length < 32) {
return {
principalId: 'unauthenticated',
policyDocument: {
Version: '2012-10-17',
Statement: [{ Action: 'execute-api:Invoke', Effect: 'Deny', Resource: event.methodArn }],
},
};
}
return {
principalId: 'validated-user',
policyDocument: {
Version: '2012-10-17',
Statement: [{ Action: 'execute-api:Invoke', Effect: 'Allow', Resource: event.methodArn }],
},
context: { normalizedPath: canonicalPath, tokenScope: 'read_write' },
};
};
Rationale: Canonicalization occurs before policy evaluation, ensuring that /v1/transactions/, /v1//transactions/, and /v1/transactions resolve to the same authorization decision. The authorizer returns a normalized path in the context payload, enabling downstream integrations to log and audit the actual resolved route.
Step 3: Enforce Gateway-Level Strict Matching
AWS HTTP APIs support strict path matching through route configuration. Combine explicit bindings with a deny-all default policy to eliminate fallback bypasses.
// Add to CDK stack after route definitions
httpApi.addRoutes({
path: '/{proxy+}',
methods: [apigwv2.HttpMethod.ANY],
authorizer: new apigwv2.HttpLambdaAuthorizer(
'CatchAllDeny',
authorizerFn,
{ authorizerResultTtl: cdk.Duration.seconds(0) }
),
integration: new apigwv2Integrations.HttpLambdaIntegration(
'DenyIntegration',
new lambda.Function(this, 'DenyHandler', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'deny.handler',
code: lambda.Code.fromInline(`
exports.handler = async () => ({
statusCode: 403,
body: JSON.stringify({ error: 'Route not authorized' })
});
`),
})
),
});
Rationale: A catch-all route bound to a strict authorizer prevents unauthenticated fallbacks. Even if the routing engine matches a greedy path, the authorizer evaluates the canonicalized path and denies access unless explicitly permitted.
Pitfall Guide
1. The Greedy Route Trap
Explanation: Developers attach {proxy+} routes to simplify deployment, assuming the authorizer will cover all variants. Greedy routes bypass exact path bindings, causing the gateway to skip authorizer invocation for trailing slash or double-slash variants.
Fix: Replace greedy routes with explicit path declarations. If a catch-all is required, bind it to a deny-by-default authorizer that validates canonical paths.
2. Implicit Path Normalization Assumptions
Explanation: Teams assume the gateway normalizes paths before invoking the authorizer. AWS HTTP APIs do not guarantee this behavior across routing and auth boundaries.
Fix: Implement explicit canonicalization inside the authorizer function. Never trust event.requestContext.http.path without normalization.
3. Query String vs Path Boundary Confusion
Explanation: Authorization policies sometimes validate query parameters instead of the request path. Query strings are mutable and easily manipulated, while path normalization affects route resolution.
Fix: Bind authorizers to path-based routes. Validate query parameters separately in the integration layer, not in the auth boundary.
4. Missing CI/CD Path Variant Testing
Explanation: Automated tests rarely cover /resource, /resource/, /resource//, and URL-encoded slash variants. Normalization mismatches remain undetected until production.
Fix: Add path variant tests to the CI pipeline. Use a test harness that sends identical payloads with trailing/double slashes and asserts consistent 401/403 responses.
5. Overlooking URL-Encoded Slashes
Explanation: Clients may send /v1%2Ftransactions or /v1/%2Ftransactions. The routing engine may decode these differently than the authorizer, creating bypass vectors.
Fix: Apply decodeURIComponent() before canonicalization. Log decoded paths for audit trails and alert on unexpected encoding patterns.
6. Authorizer Context Mismatch
Explanation: Downstream integrations rely on requestContext.authorizer for identity, but the gateway may populate it inconsistently when auth is skipped.
Fix: Validate context presence in every integration. Implement a fallback middleware that rejects requests missing required auth fields.
7. Stale Authorizer Caching
Explanation: Setting authorizerResultTtl to a high value caches authorization decisions. Path normalization changes or token revocations are ignored until cache expiration.
Fix: Use ttl: 0 during development and strict validation phases. In production, limit TTL to 300 seconds maximum and implement token revocation checks.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-security financial endpoints | Explicit route binding + canonicalizing authorizer + TTL:0 | Eliminates normalization bypass vectors; ensures strict auth evaluation | Moderate (additional Lambda invocations) |
| Public read-only APIs | Strict routes + lightweight JWT validation + TTL:300 | Balances security with latency; reduces authorizer overhead | Low |
| Legacy monolith migration | Greedy fallback + deny-by-default catch-all + path normalization in authorizer | Maintains compatibility while hardening auth boundary | Low-Moderate |
| Multi-tenant SaaS routing | Path-based authorizer + tenant context extraction + strict binding | Prevents cross-tenant path traversal via normalization gaps | Moderate |
Configuration Template
# aws-api-gateway-auth-hardening.yaml
# CloudFormation snippet for strict HTTP API with canonicalizing authorizer
Resources:
SecureHttpApi:
Type: AWS::ApiGatewayV2::Api
Properties:
Name: PaymentProcessingApi
ProtocolType: HTTP
DisableExecuteApiEndpoint: false
TokenAuthorizer:
Type: AWS::ApiGatewayV2::Authorizer
Properties:
ApiId: !Ref SecureHttpApi
Name: CanonicalPathAuthorizer
AuthorizerType: REQUEST
AuthorizerUri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${AuthLambda.Arn}/invocations
IdentitySource:
- "$request.header.Authorization"
AuthorizerResultTtlInSeconds: 0
AuthLambda:
Type: AWS::Lambda::Function
Properties:
Runtime: nodejs20.x
Handler: authorizer.handler
Code:
ZipFile: |
const normalizePath = p => decodeURIComponent(p).replace(/\/{2,}/g, '/').replace(/\/+$/, '') || '/';
exports.handler = async (e) => {
const cp = normalizePath(e.requestContext.http.path);
const allowed = ['/v1/transactions', '/v1/balances'];
const effect = allowed.includes(cp) && e.authorizationToken?.length > 32 ? 'Allow' : 'Deny';
return {
principalId: effect === 'Allow' ? 'validated' : 'anonymous',
policyDocument: {
Version: '2012-10-17',
Statement: [{ Action: 'execute-api:Invoke', Effect: effect, Resource: e.methodArn }]
},
context: { normalizedPath: cp }
};
};
Timeout: 5
MemorySize: 256
StrictRoute:
Type: AWS::ApiGatewayV2::Route
Properties:
ApiId: !Ref SecureHttpApi
RouteKey: 'POST /v1/transactions'
AuthorizationType: CUSTOM
AuthorizerId: !Ref TokenAuthorizer
Target: !Sub integrations/${TransactionIntegration}
TransactionIntegration:
Type: AWS::ApiGatewayV2::Integration
Properties:
ApiId: !Ref SecureHttpApi
IntegrationType: HTTP_PROXY
IntegrationUri: https://internal-api.example.com/v1/transactions
PayloadFormatVersion: '2.0'
Quick Start Guide
- Deploy the canonicalizing authorizer: Package the TypeScript authorizer function, set
authorizerResultTtl to 0, and attach it to your HTTP API.
- Replace greedy routes: Audit your API configuration, remove
{proxy+} bindings, and declare explicit routes for every authenticated endpoint.
- Add path variant tests: Create a test suite that sends identical requests with
/path, /path/, /path//, and %2F variants. Assert consistent 401/403 responses when tokens are missing or invalid.
- Enable monitoring: Configure CloudWatch alarms for
4xxError spikes and AuthorizerError metrics. Log normalizedPath from the authorizer context to trace bypass attempts.
- Validate in staging: Run penetration tests focusing on path normalization edge cases. Confirm that trailing slashes, double slashes, and URL-encoded variants trigger authentication consistently before promoting to production.