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.
Week 9 of our CDK migration. Everything was going smoothly until our Head of Security walked into the morning standup with a single question: "Which Lambda functions can access customer payment data?"
Twenty-three faces stared back at him in silence. Our Serverless Framework setup had grown organically over 18 months. Functions had "*"
IAM permissions because "it was faster to ship." Authorization logic was scattered across 12 different custom authorizers. We had zero audit trail of who could access what.
That question triggered a 3-week security audit that revealed 47 over-privileged functions and a $180K potential compliance fine. This is the story of rebuilding enterprise-grade authentication and authorization during a live migration - without breaking a single user session.
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
The Authentication Nightmare Audit#
Before fixing anything, we needed to understand what we had. The audit revealed our authentication hell:
The Serverless Framework Reality Check#
User Management: Three different Cognito pools across environments, manually created, zero documentation of custom attributes.
Authorization: 12 different Lambda authorizers, each with different JWT validation logic, no caching, average 400ms authorization latency.
IAM Permissions: 47 Lambda functions with wildcard permissions. Our most critical payment function had "*"
access to all DynamoDB tables.
Secrets: API keys hardcoded in environment variables, shared across environments, last rotated "sometime in 2022."
Audit Trail: None. Zero logging of authorization decisions. No way to answer "who accessed what when."
The Business Impact#
- Compliance risk: $180K potential GDPR fine for over-broad data access
- Performance impact: 400ms average authorization latency (28% of total request time)
- Operational overhead: 3 hours/week resolving authentication issues
- Security debt: 47 functions with unnecessary permissions
Production-Grade Cognito Implementation#
After the audit, we rebuilt authentication with enterprise controls. Here's the battle-tested approach:
# 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
The Enterprise-Grade CDK Approach#
Here's the Cognito implementation that passed SOC 2 audit and handles 180K+ users:
// lib/constructs/auth/production-cognito.ts
import {
UserPool,
UserPoolClient,
AccountRecovery,
Mfa,
UserPoolOperation,
StringAttribute,
ClientAttributes,
OAuthScope,
UserPoolDomain,
CognitoUserPoolsAuthorizer
} 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 },
// SOC 2 compliant password policy
passwordPolicy: {
minLength: 14, // Increased from 12 after audit
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,
// 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', 'SOC2-GDPR');
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;
};
The 400ms Authorization Disaster#
Our legacy authorization setup was killing performance. Each API request required:
- JWT decode: 50ms
- Cognito JWK fetch: 150ms (no caching)
- Signature verification: 80ms
- Database role lookup: 120ms
- Total authorization time: 400ms per request
Business impact: 28% of total request time spent on authorization. Mobile app perceived as "slow." Customer complaints about API responsiveness.
High-Performance JWT Authorization#
Here's the caching-optimized authorizer that reduced latency from 400ms to 12ms:
// 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;
}) {
// Optimized authorizer function for production
const authorizerFunction = new NodejsFunction(scope, 'OptimizedAuthorizerFunction', {
entry: 'src/auth/production-jwt-authorizer.ts',
handler: 'handler',
// Provisioned concurrency for consistent performance
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,
// 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: 'node20',
// Include only essential dependencies
nodeModules: ['jsonwebtoken', 'jwk-to-pem'],
externalModules: ['@aws-sdk/*'],
},
});
super(scope, id, {
restApi: props.api,
handler: authorizerFunction,
identitySource: IdentitySource.header('Authorization'),
// Aggressive caching for performance
resultsCacheTtl: Duration.minutes(5),
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';
// 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');
// 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;
const role = verifiedPayload['custom:role'] || 'user';
const accessLevel = verifiedPayload['custom:accessLevel'] || 'basic';
// Generate resource-specific policy
const policy = generateEnhancedPolicy(
userId,
'Allow',
event.methodArn,
{
userId,
email,
role,
accessLevel,
tokenUse: verifiedPayload.token_use,
authTime: verifiedPayload.auth_time?.toString(),
requestId,
}
);
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',
resource: string,
context: Record<string, any>
): APIGatewayAuthorizerResult {
// Generate wildcard resource for better caching
const resourceParts = resource.split('/');
const wildcardResource = resourceParts.slice(0, -1).join('/') + '/*';
return {
principalId,
policyDocument: {
Version: '2012-10-17',
Statement: [
{
Action: 'execute-api:Invoke',
Effect: effect,
Resource: wildcardResource, // Enable broader caching
},
],
},
context: {
// Convert all context values to strings (API Gateway requirement)
...Object.entries(context).reduce((acc, [key, value]) => ({
...acc,
[key]: String(value || ''),
}), {}),
},
// Enable longer TTL for stable users
ttlOverride: context.role === 'admin' ? 300 : 120, // Admin tokens cached longer
};
}
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`,
});
}
}
The Wildcard IAM Disaster#
During our security audit, we discovered our payment processing Lambda had this IAM policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "*",
"Resource": "*"
}
]
}
Translation: Our payment function could delete S3 buckets, terminate EC2 instances, or access any DynamoDB table in our account. One compromised function = total account takeover.
Business impact: $180K potential GDPR fine, failed SOC 2 audit, blocked enterprise deals.
Least Privilege IAM Architecture#
Here's the role-based system that passed our security audit and reduced permissions by 94%:
// 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
- Set up proper account recovery flows
Authorization Migration#
- Convert custom authorizers to CDK
- Implement proper caching strategies
- Test token validation thoroughly
- Verify CORS settings for auth endpoints
- 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 Best Practices#
// 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 }),
{}
),
});
});
}
Security Migration Results#
After 3 weeks of intensive security rebuilding, here are the measurable improvements:
Performance Improvements#
- Authorization latency: 400ms → 12ms (97% reduction)
- Cache hit rate: 0% → 94% (JWK caching)
- API response time: 1.4s → 0.8s average (42% improvement)
- Mobile app perceived performance: "Slow" → "Snappy" user feedback
Security Posture#
- Over-privileged functions: 47 → 0 (100% elimination)
- Wildcard IAM permissions: 23 functions → 0 functions
- Audit trail coverage: 0% → 100% (all auth events logged)
- Failed auth detection: Manual → 30-second automated alerts
- Compliance status: Failed audit → SOC 2 Type II compliant
Operational Efficiency#
- Auth troubleshooting time: 3 hours/week → 15 minutes/week
- Security incidents: 2-3/month → 0/month (6 months running)
- Authorization cache hit rate: 94% (5-minute TTL)
- JWT validation errors: 15/day → 2/day (better error handling)
Business Impact#
- Enterprise deals unblocked: $2.3M sales pipeline reopened
- Compliance audit: Passed SOC 2 Type II
- GDPR fine risk: $180K → $0 (full compliance)
- Customer trust: Visible security improvements in sales demos
Hard-Learned Security Lessons#
1. Start with Least Privilege, Always#
Before: "Action": "*"
because "it's faster to ship"
After: Explicit permissions for every function, every resource
Impact: 94% reduction in attack surface
2. Performance and Security Aren't Mutually Exclusive#
Before: "Security adds latency" After: Proper caching made auth faster AND more secure Impact: 97% latency reduction with stronger security
3. Audit Trail is Non-Negotiable#
Before: Zero visibility into who accessed what After: Every auth decision logged with full context Impact: Passed SOC 2 audit, enabled compliance
4. Cache Everything (Securely)#
Before: JWK fetch on every request After: Multi-level caching with invalidation Impact: 94% cache hit rate, sub-20ms auth
5. Role-Based Access Control Scales#
Before: Ad-hoc permissions per function After: Standardized roles with clear responsibilities Impact: Simplified management, better security
What's Next#
Your serverless application now has enterprise-grade authentication and authorization that actually performs. User management is bulletproof, APIs are protected by optimized JWT verification, and IAM policies follow strict least privilege principles.
In Part 6, we'll bring the entire migration together:
- Complete migration strategies and timelines
- Testing approaches that actually work in production
- Rollback procedures that save your career
- Performance optimization across the entire stack
- Monitoring and observability that prevents incidents
The security foundation is solid. Let's finish this migration properly.
Comments (0)
Join the conversation
Sign in to share your thoughts and engage with the community
No comments yet
Be the first to share your thoughts on this post!
Comments (0)
Join the conversation
Sign in to share your thoughts and engage with the community
No comments yet
Be the first to share your thoughts on this post!