Migrating from Serverless Framework to AWS CDK: Part 5 - Authentication, Authorization, and IAM
Implement robust authentication with Cognito, API Gateway authorizers, and fine-grained IAM policies when migrating from Serverless Framework to AWS CDK.
Migrating authentication and authorization from Serverless Framework to AWS CDK tends to surface accumulated security debt: permissions and authorization logic that grew organically and were never revisited.
Common patterns include functions with overly broad IAM permissions, scattered authorization logic across multiple custom authorizers, and insufficient audit trails for access control decisions. These issues become apparent during migration assessments and can significantly impact compliance requirements.
The workable default is one Cognito user pool per stage, a single cached token authorizer in front of API Gateway, and one narrow IAM role per Lambda function. Each piece can ship behind the existing endpoints, so the application stays available while the migration runs.
Series Navigation:
- Part 1: Why Make the Switch?
- Part 2: Setting Up Your CDK Environment
- Part 3: Migrating Lambda Functions and API Gateway
- Part 4: Database and Environment Management
- Part 5: Authentication, Authorization, and IAM (this post)
- Part 6: Migration Strategies and Best Practices
Understanding Authentication Migration Challenges#
Before implementing solutions, it’s essential to assess existing authentication patterns. Migration assessments usually surface the same set of issues. Ordering them by risk first and performance second keeps the rebuild from stalling on low-value work.
Common Serverless Framework Authentication Patterns#
User Management: Three different Cognito pools across environments, manually created, zero documentation of custom attributes.
Authorization: Multiple Lambda authorizers, each with different JWT validation logic, no caching, high authorization latency.
Wildcard IAM permissions are common too: numerous Lambda functions hold far broader resource access than they actually need.
Secrets and audit trail: API keys hardcoded in environment variables, shared across environments, and rotated infrequently. Authorization decisions leave almost no log trail, which makes access patterns hard to reconstruct later.
Migration Impact Considerations#
- Compliance risk: Potential regulatory fines for over-broad data access and insufficient access controls
- Performance impact: High authorization latency contributing to overall request time
- Operational overhead: Significant time spent resolving authentication issues and access problems
- Security debt: Multiple functions with unnecessary permissions creating expanded attack surface
Production-Grade Cognito Implementation#
The serverless.yml version below covers the user pool and password policy; it has no MFA, no device tracking, and no audit trail for sign-in attempts:
# serverless.yml
resources:
Resources:
UserPool:
Type: AWS::Cognito::UserPool
Properties:
UserPoolName: ${self:service}-${opt:stage}-users
Schema:
- Name: email
Required: true
Mutable: false
- Name: role
AttributeDataType: String
Mutable: true
AutoVerifiedAttributes:
- email
Policies:
PasswordPolicy:
MinimumLength: 8
RequireUppercase: true
RequireLowercase: true
RequireNumbers: true
RequireSymbols: true
UserPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
ClientName: ${self:service}-${opt:stage}-client
UserPoolId: !Ref UserPool
GenerateSecret: false
ExplicitAuthFlows:
- ALLOW_USER_PASSWORD_AUTH
- ALLOW_REFRESH_TOKEN_AUTH
CDK Implementation for Enterprise Authentication#
The CDK version below adds the pieces the YAML skipped: MFA in production, device tracking, advanced security mode, and Lambda triggers that write an audit trail.
// lib/constructs/auth/production-cognito.ts
import {
UserPool,
UserPoolClient,
AccountRecovery,
Mfa,
UserPoolOperation,
StringAttribute,
ClientAttributes,
OAuthScope,
UserPoolDomain,
CognitoUserPoolsAuthorizer,
AdvancedSecurityMode
} from 'aws-cdk-lib/aws-cognito';
import { Duration, RemovalPolicy, Tags } from 'aws-cdk-lib';
import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs';
import { Alarm, Metric, TreatMissingData } from 'aws-cdk-lib/aws-cloudwatch';
export class ProductionCognitoAuth extends Construct {
public readonly userPool: UserPool;
public readonly userPoolClient: UserPoolClient;
public readonly authorizer: CognitoUserPoolsAuthorizer;
constructor(scope: Construct, id: string, props: {
stage: string;
domainPrefix?: string;
callbackUrls?: string[];
api: RestApi;
}) {
super(scope, id);
// Create user pool with audit-compliant settings
this.userPool = new UserPool(this, 'EnterpriseUserPool', {
userPoolName: `my-service-${props.stage}-users-v2`,
// Enhanced security: no self-signup in production
selfSignUpEnabled: props.stage !== 'prod',
signInAliases: {
email: true,
username: false, // Email-only sign-in reduces attack surface
},
signInCaseSensitive: false,
autoVerify: { email: true },
// Enterprise-compliant password policy
passwordPolicy: {
minLength: 14, // Enterprise security requirement
requireLowercase: true,
requireUppercase: true,
requireDigits: true,
requireSymbols: true,
tempPasswordValidity: Duration.hours(24), // Reduced from 3 days
},
// Comprehensive user attributes for RBAC
standardAttributes: {
email: { required: true, mutable: false },
givenName: { required: true, mutable: true },
familyName: { required: true, mutable: true },
},
customAttributes: {
// Role-based access control
role: new StringAttribute({ mutable: true }),
department: new StringAttribute({ mutable: true }),
accessLevel: new StringAttribute({ mutable: true }),
// Audit trail attributes
lastLoginDate: new StringAttribute({ mutable: true }),
createdBy: new StringAttribute({ mutable: false }),
// Compliance attributes
dataAccessLevel: new StringAttribute({ mutable: true }),
complianceFlags: new StringAttribute({ mutable: true }),
},
// Enterprise security settings
accountRecovery: AccountRecovery.EMAIL_ONLY,
mfa: props.stage === 'prod' ? Mfa.REQUIRED : Mfa.OPTIONAL,
mfaSecondFactor: {
sms: false, // TOTP only for security
otp: true,
},
// Advanced threat protection
advancedSecurityMode: props.stage === 'prod'
? AdvancedSecurityMode.ENFORCED
: AdvancedSecurityMode.AUDIT,
enableSmsRole: false, // Disable SMS for enhanced security
// Email configuration for branded communications
emailSettings: {
from: 'noreply@yourcompany.com',
replyTo: 'support@yourcompany.com',
},
// Device tracking for security
deviceTracking: {
challengeRequiredOnNewDevice: true,
deviceOnlyRememberedOnUserPrompt: false,
},
// Data protection
removalPolicy: props.stage === 'prod' ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY,
deletionProtection: props.stage === 'prod',
});
// Add enterprise Lambda triggers
this.addSecurityTriggers(props.stage);
// Create production app client
this.userPoolClient = new UserPoolClient(this, 'EnterpriseClient', {
userPool: this.userPool,
userPoolClientName: `my-service-${props.stage}-client-v2`,
// Allowed authentication flows
authFlows: {
userPassword: false, // Disable less secure flow
userSrp: true, // Secure Remote Password protocol
custom: true, // Custom auth challenges
adminUserPassword: props.stage !== 'prod', // Admin flow only in non-prod
},
// OAuth configuration for enterprise SSO
oAuth: {
flows: {
authorizationCodeGrant: true,
implicitCodeGrant: false, // Disable implicit flow for security
clientCredentials: false,
},
scopes: [
OAuthScope.EMAIL,
OAuthScope.OPENID,
OAuthScope.PROFILE,
OAuthScope.custom('read:profile'),
OAuthScope.custom('write:profile'),
],
callbackUrls: props.callbackUrls || [],
logoutUrls: [`https://${props.stage === 'prod' ? 'app' : props.stage}.yourcompany.com/logout`],
},
generateSecret: false, // Public client for SPA
// Fine-grained attribute access
readAttributes: new ClientAttributes()
.withStandardAttributes({
email: true,
emailVerified: true,
givenName: true,
familyName: true,
})
.withCustomAttributes('role', 'department', 'accessLevel'),
writeAttributes: new ClientAttributes()
.withCustomAttributes('lastLoginDate'), // Limited write access
// Security-focused token settings
idTokenValidity: Duration.minutes(30), // Short-lived for security
accessTokenValidity: Duration.minutes(30), // Short-lived for security
refreshTokenValidity: Duration.days(1), // Daily re-authentication
// Enhanced security options
preventUserExistenceErrors: true,
enableTokenRevocation: true,
// Custom token settings
authSessionValidity: Duration.minutes(3), // Quick auth flow timeout
});
// Create API Gateway authorizer
this.authorizer = new CognitoUserPoolsAuthorizer(this, 'CognitoAuthorizer', {
cognitoUserPools: [this.userPool],
authorizerName: `${props.api.restApiName}-cognito-auth`,
identitySource: 'method.request.header.Authorization',
resultsCacheTtl: Duration.minutes(5), // Cache for performance
});
// Add custom domain for branded experience
if (props.domainPrefix) {
new UserPoolDomain(this, 'UserPoolDomain', {
userPool: this.userPool,
cognitoDomainPrefix: `${props.domainPrefix}-${props.stage}`,
});
}
// Production monitoring and alerting
this.addProductionMonitoring(props.stage);
// Compliance tagging
Tags.of(this).add('DataClassification', 'PII');
Tags.of(this).add('Compliance', 'Enterprise-Security');
Tags.of(this).add('Service', 'authentication');
Tags.of(this).add('Stage', props.stage);
}
private addSecurityTriggers(stage: string) {
// Pre-authentication security checks
const preAuthFn = new NodejsFunction(this, 'PreAuthSecurityFunction', {
entry: 'src/auth/triggers/pre-auth-security.ts',
handler: 'handler',
timeout: Duration.seconds(10),
logRetention: RetentionDays.ONE_MONTH,
environment: {
STAGE: stage,
SECURITY_LOG_LEVEL: stage === 'prod' ? 'WARN' : 'DEBUG',
},
});
this.userPool.addTrigger(UserPoolOperation.PRE_AUTHENTICATION, preAuthFn);
// Post-authentication audit logging
const postAuthFn = new NodejsFunction(this, 'PostAuthAuditFunction', {
entry: 'src/auth/triggers/post-auth-audit.ts',
handler: 'handler',
timeout: Duration.seconds(10),
logRetention: RetentionDays.ONE_YEAR, // Long retention for audit
environment: {
STAGE: stage,
AUDIT_TABLE: `auth-audit-${stage}`,
},
});
this.userPool.addTrigger(UserPoolOperation.POST_AUTHENTICATION, postAuthFn);
// User creation with RBAC setup
const postConfirmFn = new NodejsFunction(this, 'PostConfirmationRBACFunction', {
entry: 'src/auth/triggers/post-confirmation-rbac.ts',
handler: 'handler',
timeout: Duration.seconds(30),
environment: {
STAGE: stage,
USERS_TABLE: `users-${stage}`,
ROLES_TABLE: `user-roles-${stage}`,
DEFAULT_ROLE: 'viewer', // Least privilege by default
},
});
this.userPool.addTrigger(UserPoolOperation.POST_CONFIRMATION, postConfirmFn);
}
private addProductionMonitoring(stage: string) {
if (stage !== 'prod') return;
// Failed authentication alarm
new Alarm(this, 'FailedAuthAlarm', {
metric: new Metric({
namespace: 'AWS/Cognito',
metricName: 'SignInFailures',
dimensionsMap: {
UserPool: this.userPool.userPoolId,
},
statistic: 'Sum',
period: Duration.minutes(5),
}),
threshold: 50, // 50 failed attempts in 5 minutes
evaluationPeriods: 1,
treatMissingData: TreatMissingData.NOT_BREACHING,
alarmDescription: 'High number of authentication failures detected',
});
// Compromised credentials alarm
new Alarm(this, 'CompromisedCredentialsAlarm', {
metric: new Metric({
namespace: 'AWS/Cognito',
metricName: 'CompromisedCredentialsRisk',
dimensionsMap: {
UserPool: this.userPool.userPoolId,
},
statistic: 'Sum',
period: Duration.minutes(15),
}),
threshold: 1, // Any compromised credential is critical
evaluationPeriods: 1,
alarmDescription: 'Compromised credentials detected',
});
}
}
Lambda Triggers for Custom Auth Flows#
// src/auth/triggers/pre-signup.ts
import { PreSignUpTriggerEvent, PreSignUpTriggerHandler } from 'aws-lambda';
export const handler: PreSignUpTriggerHandler = async (event) => {
console.log('Pre-signup event:', JSON.stringify(event, null, 2));
// Validate email domain for corporate accounts
const email = event.request.userAttributes.email;
const allowedDomains = ['company.com', 'partner.com'];
const domain = email.split('@')[1];
if (!allowedDomains.includes(domain)) {
throw new Error('Registration is restricted to corporate email addresses');
}
// Auto-confirm corporate emails
if (domain === 'company.com') {
event.response.autoConfirmUser = true;
event.response.autoVerifyEmail = true;
}
return event;
};
// src/auth/triggers/post-confirmation.ts
import { PostConfirmationTriggerEvent, PostConfirmationTriggerHandler } from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));
export const handler: PostConfirmationTriggerHandler = async (event) => {
console.log('Post-confirmation event:', JSON.stringify(event, null, 2));
// Create user record in DynamoDB
await client.send(new PutCommand({
TableName: process.env.USERS_TABLE,
Item: {
userId: event.request.userAttributes.sub,
email: event.request.userAttributes.email,
role: event.request.userAttributes['custom:role'] || 'user',
department: event.request.userAttributes['custom:department'],
createdAt: new Date().toISOString(),
status: 'active',
},
}));
return event;
};
Authorization Performance Optimization#
Legacy authorization setups repeat the same work on every request:
- JWT decode: Parsing the token header and payload
- Cognito JWK fetch: A network call to the user pool JWKS endpoint when nothing is cached
- Signature verification: RS256 verification against the matching public key
- Database role lookup: An extra query when roles live outside the token
- No result caching: The whole sequence runs again for the next request on the same token
The JWKS fetch and the role lookup are the two steps worth removing from the request path. Both are cacheable. The signature check has to run every time.
High-Performance JWT Authorization#
The authorizer below keeps the JWKS in module scope, falls back to a stale copy when the fetch fails, and lets API Gateway cache the policy it returns. A verified signature only settles identity. The role claim decides which routes the policy covers, and a role the API does not map gets a Deny:
// lib/constructs/auth/high-performance-jwt-authorizer.ts
import {
TokenAuthorizer,
IdentitySource,
IRestApi
} from 'aws-cdk-lib/aws-apigateway';
import { Duration } from 'aws-cdk-lib';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { RetentionDays } from 'aws-cdk-lib/aws-logs';
export class HighPerformanceJwtAuthorizer extends TokenAuthorizer {
constructor(scope: Construct, id: string, props: {
api: IRestApi;
userPoolId: string;
region: string;
stage: string;
// Role claim -> the METHOD/path patterns that role may invoke,
// e.g. { viewer: ['GET/orders/*'], admin: ['*/*'] }
roleRoutes: Record<string, string[]>;
}) {
// Optimized authorizer function for production
const authorizerFunction = new NodejsFunction(scope, 'OptimizedAuthorizerFunction', {
entry: 'src/auth/production-jwt-authorizer.ts',
handler: 'handler',
// Reserved concurrency for consistent performance (not provisioned to avoid costs)
reservedConcurrentExecutions: props.stage === 'prod' ? 10 : undefined,
timeout: Duration.seconds(5), // Quick timeout for fast failures
memorySize: 512, // Optimized for JWT processing
logRetention: RetentionDays.ONE_MONTH,
environment: {
USER_POOL_ID: props.userPoolId,
REGION: props.region,
STAGE: props.stage,
// Authorization input: a role missing from this map gets no grants
ROLE_ROUTES: JSON.stringify(props.roleRoutes),
// Performance optimization flags
ENABLE_METRICS: props.stage === 'prod' ? 'true' : 'false',
CACHE_TIMEOUT_MS: '300000', // 5 minutes
},
bundling: {
// Minimize bundle size for faster cold starts
minify: true,
target: 'node22', // Latest LTS for better performance
// Include only essential dependencies
nodeModules: ['jsonwebtoken', 'jwk-to-pem'],
externalModules: ['@aws-sdk/*'],
},
});
super(scope, id, {
restApi: props.api,
handler: authorizerFunction,
identitySource: IdentitySource.header('Authorization'),
// API Gateway caching for performance (reduces Lambda invocations)
resultsCacheTtl: Duration.minutes(5), // Balance between security and performance
authorizerName: `${props.api.restApiName}-jwt-authorizer-v2`,
// Strict token validation
validationRegex: '^Bearer [A-Za-z0-9\\-_=]+\\.[A-Za-z0-9\\-_=]+\\.[A-Za-z0-9\\-_.+/=]*$',
});
}
}
// src/auth/production-jwt-authorizer.ts
import { APIGatewayTokenAuthorizerEvent, APIGatewayAuthorizerResult } from 'aws-lambda';
import jwt from 'jsonwebtoken';
import jwkToPem from 'jwk-to-pem';
// RECOMMENDED: Use AWS-provided 'aws-jwt-verify' library for new implementations
// It provides built-in optimizations, better error handling, and official AWS support.
// Example implementation with aws-jwt-verify:
//
// import { CognitoJwtVerifier } from 'aws-jwt-verify';
// const verifier = CognitoJwtVerifier.create({
// userPoolId: process.env.USER_POOL_ID!,
// tokenUse: 'access',
// clientId: process.env.CLIENT_ID,
// });
// const payload = await verifier.verify(token);
//
// This implementation uses jsonwebtoken for compatibility with existing setups.
// Multi-level caching for performance
let cachedKeys: Map<string, string> | null = null;
let cacheTimestamp: number = 0;
const CACHE_TIMEOUT = parseInt(process.env.CACHE_TIMEOUT_MS || '300000');
// Role -> allowed METHOD/path patterns, injected by the CDK construct
const ROLE_ROUTES: Record<string, string[]> = JSON.parse(process.env.ROLE_ROUTES || '{}');
// Performance metrics (collected in production)
const metrics = {
authCount: 0,
keyFetchCount: 0,
cacheHits: 0,
averageLatency: 0,
};
async function getPublicKeys(): Promise<Map<string, string>> {
const now = Date.now();
// Return cached keys if still valid
if (cachedKeys && (now - cacheTimestamp) < CACHE_TIMEOUT) {
metrics.cacheHits++;
return cachedKeys;
}
const startTime = Date.now();
metrics.keyFetchCount++;
try {
const jwksUrl = `https://cognito-idp.${process.env.REGION}.amazonaws.com/${process.env.USER_POOL_ID}/.well-known/jwks.json`;
// Use fetch with timeout and retry logic
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
const response = await fetch(jwksUrl, {
signal: controller.signal,
headers: {
'Cache-Control': 'max-age=300', // Request 5-minute cache
},
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`JWK fetch failed: ${response.status}`);
}
const jwks = await response.json();
// Convert and cache JWKs
cachedKeys = new Map();
jwks.keys.forEach((key: any) => {
try {
cachedKeys!.set(key.kid, jwkToPem(key));
} catch (error) {
console.warn(`Failed to convert JWK ${key.kid}:`, error);
}
});
cacheTimestamp = now;
const fetchTime = Date.now() - startTime;
console.log(`JWK fetch completed in ${fetchTime}ms, cached ${cachedKeys.size} keys`);
return cachedKeys;
} catch (error) {
console.error('JWK fetch failed:', error);
// Return stale cache if available as fallback
if (cachedKeys) {
console.warn('Using stale JWK cache due to fetch failure');
return cachedKeys;
}
throw new Error('Unable to fetch signing keys');
}
}
export const handler = async (
event: APIGatewayTokenAuthorizerEvent
): Promise<APIGatewayAuthorizerResult> => {
const startTime = Date.now();
metrics.authCount++;
// Enhanced request logging for audit trail
const requestId = Math.random().toString(36).substring(7);
console.log('Authorization request:', {
requestId,
methodArn: event.methodArn,
requestTime: new Date().toISOString(),
sourceIp: event.requestContext?.identity?.sourceIp,
userAgent: event.requestContext?.identity?.userAgent,
});
try {
// Early token validation
if (!event.authorizationToken || !event.authorizationToken.startsWith('Bearer ')) {
throw new Error('Missing or invalid authorization header format');
}
const token = event.authorizationToken.replace('Bearer ', '');
// Basic token format validation
const tokenParts = token.split('.');
if (tokenParts.length !== 3) {
throw new Error('Invalid JWT format');
}
// Decode token (doesn't verify signature yet)
const decodedToken = jwt.decode(token, { complete: true });
if (!decodedToken || typeof decodedToken === 'string') {
throw new Error('Invalid token structure');
}
// Validate token expiration early
const payload = decodedToken.payload as any;
const now = Math.floor(Date.now() / 1000);
if (payload.exp && payload.exp < now) {
throw new Error('Token has expired');
}
if (payload.iat && payload.iat > now + 300) {
throw new Error('Token issued in the future');
}
// Get signing keys (cached)
const keys = await getPublicKeys();
const signingKey = keys.get(decodedToken.header.kid!);
if (!signingKey) {
throw new Error(`Signing key not found for kid: ${decodedToken.header.kid}`);
}
// Verify JWT signature and claims
const verifiedPayload = jwt.verify(token, signingKey, {
algorithms: ['RS256'],
issuer: `https://cognito-idp.${process.env.REGION}.amazonaws.com/${process.env.USER_POOL_ID}`,
audience: payload.aud,
clockTolerance: 30, // Allow 30 seconds clock skew
}) as any;
// Extract user information
const userId = verifiedPayload.sub;
const email = verifiedPayload.email;
// cognito:groups rides in both ID and access tokens, custom attributes only in the ID token
const groups: string[] = verifiedPayload['cognito:groups'] || [];
const role = verifiedPayload['custom:role'] || groups[0];
const accessLevel = verifiedPayload['custom:accessLevel'] || 'basic';
const authContext = {
userId,
email,
role: role || '',
accessLevel,
tokenUse: verifiedPayload.token_use,
authTime: verifiedPayload.auth_time?.toString(),
requestId,
};
// Signature verification only establishes identity. A role missing from
// ROLE_ROUTES gets an explicit Deny, which API Gateway returns as 403.
const allowedRoutes = role ? ROLE_ROUTES[role] : undefined;
if (!allowedRoutes || allowedRoutes.length === 0) {
console.warn('Authorization denied:', { requestId, userId, role: role || 'none' });
return generateEnhancedPolicy(userId, 'Deny', event.methodArn, [], authContext);
}
const policy = generateEnhancedPolicy(
userId,
'Allow',
event.methodArn,
allowedRoutes,
authContext
);
const totalTime = Date.now() - startTime;
metrics.averageLatency = (metrics.averageLatency + totalTime) / 2;
// Log successful authorization
console.log('Authorization successful:', {
requestId,
userId,
email,
role,
accessLevel,
latency: totalTime,
});
// Report metrics periodically
if (metrics.authCount % 100 === 0 && process.env.ENABLE_METRICS === 'true') {
console.log('Authorization metrics:', {
totalAuthorizations: metrics.authCount,
keyFetches: metrics.keyFetchCount,
cacheHitRate: (metrics.cacheHits / metrics.authCount * 100).toFixed(2) + '%',
averageLatency: metrics.averageLatency.toFixed(2) + 'ms',
});
}
return policy;
} catch (error) {
const totalTime = Date.now() - startTime;
console.error('Authorization failed:', {
requestId,
error: error.message,
latency: totalTime,
stackTrace: error.stack,
});
// For debugging in non-production
if (process.env.STAGE !== 'prod') {
console.debug('Token details:', {
token: event.authorizationToken,
methodArn: event.methodArn,
});
}
throw new Error('Unauthorized'); // Always return generic error to client
}
};
function generateEnhancedPolicy(
principalId: string,
effect: 'Allow' | 'Deny',
methodArn: string,
allowedRoutes: string[],
context: Record<string, any>
): APIGatewayAuthorizerResult {
// arn:aws:execute-api:region:account:apiId/stage/METHOD/resource/path
const [apiArn, stage] = methodArn.split('/');
// The cached result is reused for every path this token touches, so the
// statement lists the role's own routes instead of the requested one.
const resources = effect === 'Allow'
? allowedRoutes.map(route => `${apiArn}/${stage}/${route}`)
: [`${apiArn}/${stage}/*`];
return {
principalId,
policyDocument: {
Version: '2012-10-17',
Statement: [
{
Action: 'execute-api:Invoke',
Effect: effect,
Resource: resources,
},
],
},
context: {
// Convert all context values to strings (API Gateway requirement)
...Object.entries(context).reduce((acc, [key, value]) => ({
...acc,
[key]: String(value || ''),
}), {}),
},
};
}
Request-Based Authorizer with Groups#
// lib/constructs/auth/group-authorizer.ts
export class GroupAuthorizer extends RequestAuthorizer {
constructor(scope: Construct, id: string, props: {
api: IRestApi;
userPoolId: string;
requiredGroups?: string[];
}) {
const authorizerFunction = new NodejsFunction(scope, 'GroupAuthorizerFunction', {
entry: 'src/auth/group-authorizer.ts',
handler: 'handler',
environment: {
USER_POOL_ID: props.userPoolId,
REQUIRED_GROUPS: JSON.stringify(props.requiredGroups || []),
},
});
super(scope, id, {
restApi: props.api,
handler: authorizerFunction,
identitySources: [IdentitySource.header('Authorization')],
resultsCacheTtl: Duration.minutes(5),
authorizerName: `${props.api.restApiName}-group-authorizer`,
});
}
}
Wildcard IAM Permissions#
Security assessments often reveal functions with overly broad IAM policies. A typical problematic configuration:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "*",
"Resource": "*"
}
]
}
Impact: A function carrying "Action": "*" can delete S3 buckets, terminate EC2 instances, or read any DynamoDB table in the account. One compromised function becomes an account-wide compromise.
Wildcard policies are also usually the first thing a security review flags, which can block compliance sign-off on its own.
Least Privilege IAM Architecture#
The role-based construct below gives each function only the permissions it needs:
// lib/constructs/security/lambda-role.ts
import { Role, PolicyStatement, Effect, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
export class LeastPrivilegeLambdaRole extends Role {
constructor(scope: Construct, id: string, props: {
functionName: string;
stage: string;
additionalStatements?: PolicyStatement[];
}) {
super(scope, id, {
assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
roleName: `${props.functionName}-${props.stage}-role`,
description: `Execution role for ${props.functionName}`,
});
// Basic Lambda permissions
this.addToPolicy(new PolicyStatement({
effect: Effect.ALLOW,
actions: [
'logs:CreateLogGroup',
'logs:CreateLogStream',
'logs:PutLogEvents',
],
resources: [
`arn:aws:logs:*:*:log-group:/aws/lambda/${props.functionName}-*`,
],
}));
// X-Ray tracing
this.addToPolicy(new PolicyStatement({
effect: Effect.ALLOW,
actions: [
'xray:PutTraceSegments',
'xray:PutTelemetryRecords',
],
resources: ['*'],
}));
// Add custom statements
props.additionalStatements?.forEach(statement => {
this.addToPolicy(statement);
});
}
}
Resource-Based Policies#
// lib/constructs/security/resource-policies.ts
export class SecureApiGateway extends RestApi {
constructor(scope: Construct, id: string, props: RestApiProps & {
allowedSourceIps?: string[];
allowedVpcs?: string[];
}) {
super(scope, id, props);
if (props.allowedSourceIps || props.allowedVpcs) {
const conditions: any = {};
if (props.allowedSourceIps) {
conditions['IpAddress'] = {
'aws:SourceIp': props.allowedSourceIps,
};
}
if (props.allowedVpcs) {
conditions['StringEquals'] = {
'aws:SourceVpc': props.allowedVpcs,
};
}
this.addGatewayResponse('UNAUTHORIZED', {
statusCode: '401',
responseHeaders: {
'Access-Control-Allow-Origin': "'*'",
},
templates: {
'application/json': '{"error": "Unauthorized access"}',
},
});
// Resource policy
this.node.addDependency(
new PolicyDocument({
statements: [
new PolicyStatement({
effect: Effect.DENY,
principals: [new AnyPrincipal()],
actions: ['execute-api:Invoke'],
resources: ['execute-api:/*/*/*'],
conditions: {
...conditions,
},
}),
new PolicyStatement({
effect: Effect.ALLOW,
principals: [new AnyPrincipal()],
actions: ['execute-api:Invoke'],
resources: ['execute-api:/*/*/*'],
}),
],
})
);
}
}
}
Cross-Service Authentication#
Service-to-Service Auth with IAM#
// lib/constructs/auth/service-auth.ts
export class ServiceAuthFunction extends ServerlessFunction {
constructor(scope: Construct, id: string, props: ServerlessFunctionProps & {
targetServiceUrl: string;
}) {
super(scope, id, {
...props,
environment: {
...props.environment,
TARGET_SERVICE_URL: props.targetServiceUrl,
},
});
// Grant permission to invoke other services
this.addToRolePolicy(new PolicyStatement({
effect: Effect.ALLOW,
actions: ['execute-api:Invoke'],
resources: [
`arn:aws:execute-api:${Stack.of(this).region}:*:*/*/*/*`,
],
}));
}
}
// src/libs/service-client.ts
import { SignatureV4 } from '@aws-sdk/signature-v4';
import { Sha256 } from '@aws-crypto/sha256-js';
export class ServiceClient {
private signer: SignatureV4;
constructor(private baseUrl: string) {
this.signer = new SignatureV4({
service: 'execute-api',
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
sessionToken: process.env.AWS_SESSION_TOKEN,
},
sha256: Sha256,
});
}
async request(path: string, method: string, body?: any) {
const url = new URL(path, this.baseUrl);
const signedRequest = await this.signer.sign({
method,
hostname: url.hostname,
path: url.pathname,
protocol: url.protocol,
headers: {
'Content-Type': 'application/json',
host: url.hostname,
},
body: body ? JSON.stringify(body) : undefined,
});
const response = await fetch(url.toString(), {
method,
headers: signedRequest.headers,
body: signedRequest.body,
});
return response.json();
}
}
API Key Management#
Secure API Key Distribution#
// lib/constructs/auth/api-key-manager.ts
export class ApiKeyManager extends Construct {
private keys: Map<string, IApiKey> = new Map();
constructor(scope: Construct, id: string, props: {
api: IRestApi;
stage: string;
}) {
super(scope, id);
// Usage plan for rate limiting
const plan = new UsagePlan(this, 'UsagePlan', {
name: `${props.api.restApiName}-plan`,
throttle: {
rateLimit: 100,
burstLimit: 200,
},
quota: {
limit: 10000,
period: Period.DAY,
},
});
plan.addApiStage({
stage: props.api.deploymentStage,
});
}
createApiKey(name: string, customerId: string): IApiKey {
const key = new ApiKey(this, `ApiKey-${name}`, {
apiKeyName: `${name}-key`,
description: `API key for ${name}`,
customerId,
generateDistinctId: true,
});
// Store in Secrets Manager
const secret = new Secret(this, `ApiKeySecret-${name}`, {
secretName: `/api-keys/${name}`,
generateSecretString: {
secretStringTemplate: JSON.stringify({ customerId }),
generateStringKey: 'apiKey',
includeSpace: false,
},
});
// Associate key value with secret
new CustomResource(this, `StoreApiKey-${name}`, {
serviceToken: this.getKeyStorageFunction().functionArn,
properties: {
SecretId: secret.secretArn,
ApiKeyId: key.keyId,
},
});
this.keys.set(name, key);
return key;
}
}
Migration Security Checklist#
Authentication Migration#
- Map Cognito user attributes to existing schema
- Implement user migration Lambda trigger
- Test password policy compatibility
- Verify MFA settings match requirements
Authorization Migration#
- Convert custom authorizers to CDK
- Implement proper caching strategies
- Map existing roles to new structure
IAM Migration#
- Audit existing Lambda roles
- Implement least privilege principles
- Remove wildcard permissions
- Add resource-based policies where needed
- Test cross-account access if required
Security Response Headers#
// lib/constructs/security/security-headers.ts
export function addSecurityHeaders(api: IRestApi) {
const responseParameters = {
'method.response.header.X-Content-Type-Options': "'nosniff'",
'method.response.header.X-Frame-Options': "'DENY'",
'method.response.header.X-XSS-Protection': "'1; mode=block'",
'method.response.header.Strict-Transport-Security':
"'max-age=31536000; includeSubDomains'",
'method.response.header.Content-Security-Policy':
"'default-src 'self'",
};
// Add to all methods
api.methods.forEach(method => {
method.addMethodResponse({
statusCode: '200',
responseParameters: Object.keys(responseParameters).reduce(
(acc, key) => ({ ...acc, [key]: true }),
{}
),
});
});
}
CDK Version Compatibility#
This implementation targets AWS CDK v2.100+. Cognito properties move between CDK versions, and the advanced threat protection configuration has changed shape more than once. Check the CDK API reference for your pinned version before copying the user pool settings.
Anti-Patterns and Their Replacements#
1. Wildcard permissions for speed#
Anti-pattern: "Action": "*" because scoping the policy takes longer than shipping.
Replacement: Explicit actions and resource ARNs per function, scoped so a compromised function can only reach what its role names.
2. Caching without a plan#
Uncached JWT verification on every request often gets blamed on “security overhead” when the endpoints run slow, or the opposite mistake ships instead: a JWKS cache with no TTL and no fallback. The fix is to cache the JWKS in module scope with a TTL, let API Gateway cache the authorizer result, and fall back to the stale copy when a fetch fails. Most of that cost sits in the network call needed to fetch the JWKS, well above the cryptographic check itself, so caching the fetch is what actually saves latency. Key rotation still has to land eventually, and a JWKS outage should not take authorization down with it.
3. No audit trail#
Authorization decisions that leave no record behind are still common. The fix is access logging on the API Gateway stage, with $context.authorizer.userId and $context.authorizer.role in the log format next to the request ID and the resource path. A post-authentication trigger only sees the sign-in, and a cached authorizer result skips the Lambda on the calls that follow, so the access log is the one place every request lands.
4. Ad-hoc permissions per function#
Anti-pattern: Every function carries its own hand-written policy. Replacement: A shared least-privilege role construct that accepts per-function additions. Why: A single shared construct is easier to review than the dozens of bespoke policies an ad-hoc approach produces, and each of those policies gets read from scratch during every audit.
What’s Next#
The default holds when users live in one Cognito pool per stage and every authorization decision can be made from token claims: a cached token authorizer plus per-function roles keeps both the request path and the blast radius small. Override it when a decision depends on data the token cannot carry, such as per-record ownership. Then the check belongs in the handler, and the authorizer is left to prove identity only.
Part 6 covers migration strategies and timelines, testing approaches, rollback procedures, performance work across the whole stack, and the monitoring that catches regressions early.
References#
- Amazon Cognito user pools (opens in new tab) - User directory setup, authentication flows, MFA, and advanced security features in AWS CDK
- Security best practices in IAM (opens in new tab) - Least-privilege principles, removing wildcard permissions, and resource-scoped policies
- Amazon API Gateway Developer Guide (opens in new tab) - API Gateway authorizer configuration, usage plans, API keys, and request validation
- AWS CDK v2 Developer Guide (opens in new tab) - CDK constructs for Cognito, IAM roles, and API Gateway authorizers
- AWS Secrets Manager - What is it? (opens in new tab) - Secure storage of client secrets and credentials used by authentication flows
- What is AWS Lambda? - AWS Lambda (opens in new tab) - Lambda execution roles, resource-based policies, and IAM permission model for serverless functions
- Security best practices and use cases in IAM (opens in new tab) - Practical IAM patterns relevant to migrating from overly broad Serverless Framework roles
Migrating from Serverless Framework to AWS CDK
A comprehensive 6-part guide covering the complete migration process from Serverless Framework to AWS CDK, including setup, implementation patterns, and best practices.
All posts in this series
Related posts
The exact IAM size, attach, and quota limits you will hit at scale, and the scoped-policy, permission-boundary, and SCP structure that keeps you far from every one.
aws · iam · security +2
Build SaaS authorization with AWS Cognito and Verified Permissions, covering Cedar policies, multi-tenant patterns, JWT flow, and cost in TypeScript.
authorization · aws · authentication +4
A technical guide to advanced Amazon Cognito: custom auth flows, federation, multi-tenancy, migration strategies, and production-grade security with CDK.
aws · authentication · serverless +6
Definitions, implementation context, and defaults for authentication, token, access control, and Zero Trust terminology that teams argue about.
security · authentication · oauth2 +2
One-size-fits-all auth is a myth: banking, healthcare, e-commerce and SaaS each shape the authentication architecture differently.
authentication · authorization · security +5