Build a URL Shortener with AWS CDK Part 3: Security & Custom Domains
Implementing custom domains, bulk operations, URL expiration, and defense-in-depth security measures for production link shortener services.
Building a production link shortener takes more than creating short URLs. It takes security measures that absorb legitimate scale while blocking abuse. Link shorteners are attractive targets for malicious actors who use them to distribute harmful content, bypass security filters, and run phishing campaigns.
Modern link shortener services need defense-in-depth protection combining input validation, rate limiting, authentication, and real-time monitoring. This approach protects both your service and the users who click shortened links.
In Part 1 and Part 2, we built the foundation and core redirect functionality. Now let’s add the advanced features and security measures that separate a toy project from a production service.
Custom Domain Configuration
Custom domains come before security here, because the certificate work has the longest lead time. Your marketing team will eventually ask for branded short URLs like acme.co/promo instead of yourdomain.com/abc123. Here’s how to make it work:
// lib/custom-domain-stack.ts
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as targets from 'aws-cdk-lib/aws-route53-targets';
export class CustomDomainStack extends Stack {
public readonly customDomainName: apigateway.DomainName;
constructor(scope: Construct, id: string, props: StackProps & {
domainName: string;
hostedZoneId: string;
certificateArn: string; // Pre-created ACM certificate
restApi: apigateway.RestApi;
}) {
super(scope, id, props);
// Import existing hosted zone
const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, 'HostedZone', {
hostedZoneId: props.hostedZoneId,
zoneName: props.domainName,
});
// Import existing certificate (must be in us-east-1 for API Gateway)
const certificate = acm.Certificate.fromCertificateArn(
this,
'Certificate',
props.certificateArn
);
// Create custom domain name for API Gateway
this.customDomainName = new apigateway.DomainName(this, 'CustomDomain', {
domainName: props.domainName,
certificate: certificate,
securityPolicy: apigateway.SecurityPolicy.TLS_1_2,
endpointType: apigateway.EndpointType.EDGE,
});
// Map the custom domain to our API
this.customDomainName.addBasePathMapping(props.restApi, {
basePath: '', // Root path
});
// Create Route53 alias record
new route53.ARecord(this, 'CustomDomainAlias', {
zone: hostedZone,
target: route53.RecordTarget.fromAlias(
new targets.ApiGatewayDomain(this.customDomainName)
),
});
}
}
Important: API Gateway edge-optimized endpoints only accept ACM certificates issued in us-east-1, no matter which region hosts the rest of your stack. A regional custom domain uses a certificate from its own region instead.
Bulk Operation Architecture
Marketing teams love bulk operations. The implementation below keeps Lambda concurrency under control by splitting small batches from large ones:
// lambda/bulk-create.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';
import { nanoid } from 'nanoid';
const dynamodb = new DynamoDBClient({});
const sqs = new SQSClient({});
interface BulkCreateRequest {
urls: Array<{
originalUrl: string;
customSlug?: string;
expiresAt?: string;
tags?: string[];
}>;
userId: string;
}
export async function handler(
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> {
try {
const request: BulkCreateRequest = JSON.parse(event.body || '{}');
// Validate batch size to prevent resource exhaustion
if (request.urls.length > 1000) {
return {
statusCode: 400,
body: JSON.stringify({
error: 'Batch size cannot exceed 1000 URLs'
}),
};
}
// For large batches, use SQS for async processing
if (request.urls.length > 100) {
const jobId = nanoid();
await sqs.send(new SendMessageCommand({
QueueUrl: process.env.BULK_PROCESSING_QUEUE_URL,
MessageBody: JSON.stringify({
jobId,
userId: request.userId,
urls: request.urls,
}),
MessageAttributes: {
jobType: {
DataType: 'String',
StringValue: 'BULK_CREATE'
}
}
}));
return {
statusCode: 202,
body: JSON.stringify({
jobId,
message: 'Bulk creation job queued',
estimatedCompletionTime: Math.ceil(request.urls.length / 10) + ' minutes'
}),
};
}
// Process small batches synchronously
const results = await Promise.allSettled(
request.urls.map(async (urlData) => {
const shortCode = urlData.customSlug || nanoid(8);
// Validate URL before creating
if (!isValidUrl(urlData.originalUrl)) {
throw new Error(`Invalid URL: ${urlData.originalUrl}`);
}
// Check for malicious content (more on this later)
await validateUrlSafety(urlData.originalUrl);
return await createShortUrl({
shortCode,
originalUrl: urlData.originalUrl,
userId: request.userId,
expiresAt: urlData.expiresAt,
tags: urlData.tags || [],
});
})
);
const successful = results
.filter(result => result.status === 'fulfilled')
.map(result => (result as PromiseFulfilledResult<any>).value);
const failed = results
.filter(result => result.status === 'rejected')
.map(result => (result as PromiseRejectedResult).reason.message);
return {
statusCode: 200,
body: JSON.stringify({
successful: successful.length,
failed: failed.length,
errors: failed,
urls: successful,
}),
};
} catch (error) {
console.error('Bulk create error:', error);
return {
statusCode: 500,
body: JSON.stringify({ error: 'Internal server error' }),
};
}
}
function isValidUrl(url: string): boolean {
try {
const parsedUrl = new URL(url);
return ['http:', 'https:'].includes(parsedUrl.protocol);
} catch {
return false;
}
}
async function validateUrlSafety(url: string): Promise<void> {
// Implementation coming up in security section
// This is where we check against malicious domains
}
URL Expiration and Scheduling: Time-Based Features
Marketing campaigns need expiration dates. Here’s how to implement URL expiration without running expensive cleanup jobs:
// lambda/redirect-with-expiration.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient, GetItemCommand, UpdateItemCommand } from '@aws-sdk/client-dynamodb';
import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
const dynamodb = new DynamoDBClient({});
export async function handler(
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> {
const shortCode = event.pathParameters?.shortCode;
if (!shortCode) {
return {
statusCode: 404,
body: JSON.stringify({ error: 'Short code not found' }),
};
}
try {
const response = await dynamodb.send(new GetItemCommand({
TableName: process.env.URLS_TABLE_NAME,
Key: marshall({ shortCode }),
}));
if (!response.Item) {
return {
statusCode: 404,
headers: {
'Content-Type': 'text/html',
},
body: createNotFoundPage(),
};
}
const item = unmarshall(response.Item);
// Check expiration
if (item.expiresAt && new Date(item.expiresAt) < new Date()) {
// URL expired - optionally log this for analytics
await recordExpiredAccess(shortCode, item.userId);
return {
statusCode: 410, // Gone
headers: {
'Content-Type': 'text/html',
},
body: createExpiredPage(item.originalUrl),
};
}
// Check if URL is scheduled for future activation
if (item.activateAt && new Date(item.activateAt) > new Date()) {
return {
statusCode: 404, // Not yet active
headers: {
'Content-Type': 'text/html',
},
body: createNotYetActivePage(),
};
}
// Update click count asynchronously (fire and forget)
updateClickCount(shortCode, event).catch(console.error);
return {
statusCode: 302,
headers: {
Location: item.originalUrl,
'Cache-Control': 'no-cache', // Important for expired URLs
},
body: '',
};
} catch (error) {
console.error('Redirect error:', error);
return {
statusCode: 500,
headers: {
'Content-Type': 'text/html',
},
body: createErrorPage(),
};
}
}
async function recordExpiredAccess(shortCode: string, userId: string): Promise<void> {
// Record that someone tried to access an expired URL
// Useful for analytics and potential abuse detection
try {
await dynamodb.send(new UpdateItemCommand({
TableName: process.env.ANALYTICS_TABLE_NAME,
Key: marshall({
pk: `USER#${userId}`,
sk: `EXPIRED#${shortCode}#${Date.now()}`,
}),
UpdateExpression: 'SET #count = if_not_exists(#count, :zero) + :inc',
ExpressionAttributeNames: {
'#count': 'expiredAccessCount',
},
ExpressionAttributeValues: marshall({
':zero': 0,
':inc': 1,
}),
}));
} catch (error) {
console.error('Failed to record expired access:', error);
}
}
function createExpiredPage(originalUrl: string): string {
return `
<!DOCTYPE html>
<html>
<head>
<title>Link Expired</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; margin-top: 100px; }
.container { max-width: 500px; margin: 0 auto; }
</style>
</head>
<body>
<div class="container">
<h1>Link Expired</h1>
<p>This link has expired and is no longer available.</p>
<p>Original destination: <code>${originalUrl}</code></p>
<a href="/">Go to homepage</a>
</div>
</body>
</html>
`;
}
Security: Defense in Depth
Security is what keeps a link shortener from turning into a malware distribution channel. The four layers below run in order of cost: the cheapest checks reject bad input before the expensive ones ever execute.
Layer 1: Input Validation and URL Safety
// lambda/url-validator.ts
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
import { marshall } from '@aws-sdk/util-dynamodb';
const dynamodb = new DynamoDBClient({});
// Malicious domain blacklist (this should be regularly updated)
const MALICIOUS_DOMAINS = new Set<string>([
// Add known malicious domains here
// In production, load this from DynamoDB or Parameter Store
]);
// URL patterns that are commonly abused
const SUSPICIOUS_PATTERNS = [
/bit\.ly/i, // Nested shorteners
/tinyurl\.com/i, // Nested shorteners
/localhost/i, // Local development
/192\.168\./i, // Private networks
/127\.0\.0\.1/i, // Localhost
/10\./i, // Private networks
/172\.16\./i, // Private networks
];
export async function validateUrlSafety(url: string): Promise<{
isValid: boolean;
reason?: string;
}> {
try {
const parsedUrl = new URL(url);
// Check protocol
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
return {
isValid: false,
reason: 'Only HTTP and HTTPS URLs are allowed'
};
}
// Check for private/local addresses
if (SUSPICIOUS_PATTERNS.some(pattern => pattern.test(url))) {
return {
isValid: false,
reason: 'URL contains suspicious patterns'
};
}
// Check against malicious domain blacklist
if (MALICIOUS_DOMAINS.has(parsedUrl.hostname.toLowerCase())) {
return {
isValid: false,
reason: 'Domain is blacklisted'
};
}
// Check against dynamic blacklist in DynamoDB
const blacklistCheck = await dynamodb.send(new GetItemCommand({
TableName: process.env.BLACKLIST_TABLE_NAME,
Key: marshall({
domain: parsedUrl.hostname.toLowerCase()
}),
}));
if (blacklistCheck.Item) {
return {
isValid: false,
reason: 'Domain is blacklisted'
};
}
// Optional: Check against external reputation services
const reputationCheck = await checkUrlReputation(url);
if (!reputationCheck.isValid) {
return reputationCheck;
}
return { isValid: true };
} catch (error) {
return {
isValid: false,
reason: 'Invalid URL format'
};
}
}
async function checkUrlReputation(url: string): Promise<{
isValid: boolean;
reason?: string;
}> {
// In production, integrate with services like:
// - Google Safe Browsing API
// - VirusTotal API
// - URLVoid API
// For now, return valid
return { isValid: true };
}
Layer 2: Authentication and Authorization
// lambda/authorizer.ts
import { APIGatewayTokenAuthorizerEvent, APIGatewayAuthorizerResult } from 'aws-lambda';
import { verify } from 'jsonwebtoken';
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
const dynamodb = new DynamoDBClient({});
interface JWTPayload {
sub: string;
email: string;
role: string;
exp: number;
}
export async function handler(
event: APIGatewayTokenAuthorizerEvent
): Promise<APIGatewayAuthorizerResult> {
try {
const token = event.authorizationToken?.replace('Bearer ', '');
if (!token) {
throw new Error('No token provided');
}
// Verify JWT token
const decoded = verify(token, process.env.JWT_SECRET!) as JWTPayload;
// Get user details from DynamoDB
const userResponse = await dynamodb.send(new GetItemCommand({
TableName: process.env.USERS_TABLE_NAME,
Key: marshall({ userId: decoded.sub }),
}));
if (!userResponse.Item) {
throw new Error('User not found');
}
const user = unmarshall(userResponse.Item);
// Check if user is active
if (user.status !== 'ACTIVE') {
throw new Error('User is not active');
}
// Generate policy based on user role
const policy = generatePolicy(decoded.sub, 'Allow', event.methodArn, user.role);
// Add user context to be available in Lambda functions
policy.context = {
userId: decoded.sub,
email: decoded.email,
role: user.role,
planType: user.planType || 'free',
};
return policy;
} catch (error) {
console.error('Authorization failed:', error);
throw new Error('Unauthorized');
}
}
function generatePolicy(
principalId: string,
effect: 'Allow' | 'Deny',
resource: string,
role: string
): APIGatewayAuthorizerResult {
const policyDocument = {
Version: '2012-10-17',
Statement: [
{
Action: 'execute-api:Invoke',
Effect: effect,
Resource: resource,
},
],
};
// Role-based permissions
if (role === 'admin') {
// Admins can access all endpoints
policyDocument.Statement[0].Resource = '*';
} else if (role === 'premium') {
// Premium users get access to advanced features
policyDocument.Statement.push({
Action: 'execute-api:Invoke',
Effect: 'Allow',
Resource: resource.replace('/create', '/bulk-create'),
});
}
return {
principalId,
policyDocument,
};
}
Layer 3: Rate Limiting and Abuse Protection
// lambda/rate-limiter.ts
import { APIGatewayProxyResult } from 'aws-lambda';
import {
BatchGetItemCommand,
DynamoDBClient,
TransactWriteItemsCommand,
} from '@aws-sdk/client-dynamodb';
import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
const dynamodb = new DynamoDBClient({});
const TABLE_NAME = process.env.RATE_LIMIT_TABLE_NAME!;
interface RateLimitConfig {
requestsPerMinute: number;
requestsPerHour: number;
requestsPerDay: number;
}
const RATE_LIMITS: Record<string, RateLimitConfig> = {
free: {
requestsPerMinute: 10,
requestsPerHour: 100,
requestsPerDay: 1000,
},
premium: {
requestsPerMinute: 100,
requestsPerHour: 1000,
requestsPerDay: 10000,
},
admin: {
requestsPerMinute: 1000,
requestsPerHour: 10000,
requestsPerDay: 100000,
},
};
// One item per user per window. DynamoDB rejects an update expression that
// SETs and ADDs the same attribute, so each counter gets its own item and a
// TTL attribute removes windows that have passed.
const WINDOW_SIZES_MS = {
MINUTE: 60 * 1000,
HOUR: 60 * 60 * 1000,
DAY: 24 * 60 * 60 * 1000,
};
export async function checkRateLimit(
userId: string,
planType: string = 'free',
clientIp?: string
): Promise<{
allowed: boolean;
resetTime?: number;
remainingRequests?: number;
}> {
const config = RATE_LIMITS[planType] || RATE_LIMITS.free;
const now = Date.now();
const windows = [
{ name: 'MINUTE', size: WINDOW_SIZES_MS.MINUTE, limit: config.requestsPerMinute },
{ name: 'HOUR', size: WINDOW_SIZES_MS.HOUR, limit: config.requestsPerHour },
{ name: 'DAY', size: WINDOW_SIZES_MS.DAY, limit: config.requestsPerDay },
].map((window) => {
const bucket = Math.floor(now / window.size);
return {
...window,
windowId: `${window.name}#${bucket}`,
resetTime: (bucket + 1) * window.size,
};
});
try {
// Conditional ADD, so two concurrent invocations cannot both slip past a
// limit. All three windows go in one transaction: every counter moves or
// none of them does. Incrementing them one by one lets a request that the
// daily limit rejects still spend a minute slot and an hour slot on its
// way out, which keeps the user blocked after the daily window resets.
await dynamodb.send(new TransactWriteItemsCommand({
TransactItems: windows.map((window) => ({
Update: {
TableName: TABLE_NAME,
Key: marshall({ userId, window: window.windowId }),
UpdateExpression: 'ADD #count :inc SET #ttl = :ttl',
ConditionExpression: 'attribute_not_exists(#count) OR #count < :limit',
ExpressionAttributeNames: {
'#count': 'requests',
'#ttl': 'expiresAt',
},
ExpressionAttributeValues: marshall({
':inc': 1,
':limit': window.limit,
':ttl': Math.ceil(window.resetTime / 1000) + 60,
}),
},
})),
}));
} catch (error: any) {
if (error.name === 'TransactionCanceledException') {
const blocked = windows.filter(
(_, index) => error.CancellationReasons?.[index]?.Code === 'ConditionalCheckFailed'
);
if (blocked.length > 0) {
return {
allowed: false,
// Every window is evaluated in the same call, so report the one
// that frees up last.
resetTime: Math.max(...blocked.map((window) => window.resetTime)),
remainingRequests: 0,
};
}
}
// Anything else, a TransactionConflict from a concurrent check included,
// is a genuine failure and belongs to the caller.
throw error;
}
// A transaction returns no attribute values, so the remaining budget needs
// a consistent read of the counters we just moved.
const counters = await dynamodb.send(new BatchGetItemCommand({
RequestItems: {
[TABLE_NAME]: {
Keys: windows.map((window) => marshall({ userId, window: window.windowId })),
ConsistentRead: true,
},
},
}));
const counts: Record<string, number> = {};
for (const item of counters.Responses?.[TABLE_NAME] || []) {
const counter = unmarshall(item);
counts[counter.window] = counter.requests;
}
return {
allowed: true,
remainingRequests: Math.min(
...windows.map((window) => window.limit - (counts[window.windowId] || 0))
),
};
}
export function createRateLimitResponse(resetTime: number): APIGatewayProxyResult {
return {
statusCode: 429,
headers: {
'X-RateLimit-Reset': Math.ceil(resetTime / 1000).toString(),
'Retry-After': Math.ceil((resetTime - Date.now()) / 1000).toString(),
},
body: JSON.stringify({
error: 'Rate limit exceeded',
message: 'Too many requests. Please try again later.',
resetTime: new Date(resetTime).toISOString(),
}),
};
}
Layer 4: AWS WAF Protection
// lib/waf-stack.ts
import { Fn, Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as wafv2 from 'aws-cdk-lib/aws-wafv2';
import * as logs from 'aws-cdk-lib/aws-logs';
export class WAFStack extends Stack {
public readonly webAcl: wafv2.CfnWebACL;
constructor(scope: Construct, id: string, props: StackProps) {
super(scope, id, props);
// Create CloudWatch log group for WAF logs.
// WAF only accepts log group names that start with "aws-waf-logs-".
const logGroup = new logs.LogGroup(this, 'WAFLogGroup', {
logGroupName: 'aws-waf-logs-link-shortener',
retention: logs.RetentionDays.ONE_MONTH,
});
this.webAcl = new wafv2.CfnWebACL(this, 'LinkShortenerWAF', {
scope: 'CLOUDFRONT', // Use REGIONAL for ALB/API Gateway
defaultAction: { allow: {} },
rules: [
// Rule 1: AWS Managed Rules - Core Rule Set
{
name: 'AWSManagedRulesCore',
priority: 1,
statement: {
managedRuleGroupStatement: {
vendorName: 'AWS',
name: 'AWSManagedRulesCommonRuleSet',
},
},
overrideAction: { none: {} },
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'AWSManagedRulesCore',
},
},
// Rule 2: Rate limiting for URL creation
{
name: 'RateLimitCreation',
priority: 2,
statement: {
rateBasedStatement: {
limit: 1000, // requests per 5-minute window
aggregateKeyType: 'IP',
scopeDownStatement: {
byteMatchStatement: {
searchString: '/create',
fieldToMatch: { uriPath: {} },
textTransformations: [
{ priority: 0, type: 'LOWERCASE' },
],
positionalConstraint: 'CONTAINS',
},
},
},
},
action: {
block: {
customResponse: {
responseCode: 429,
customResponseBodyKey: 'RateLimitExceeded',
},
},
},
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'RateLimitCreation',
},
},
// Rule 3: Block known bot networks
{
name: 'AWSManagedRulesBot',
priority: 3,
statement: {
managedRuleGroupStatement: {
vendorName: 'AWS',
name: 'AWSManagedRulesBotControlRuleSet',
},
},
overrideAction: { none: {} },
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'AWSManagedRulesBot',
},
},
// Rule 4: IP reputation list
{
name: 'AWSManagedRulesIPReputation',
priority: 4,
statement: {
managedRuleGroupStatement: {
vendorName: 'AWS',
name: 'AWSManagedRulesAmazonIpReputationList',
},
},
overrideAction: { none: {} },
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'AWSManagedRulesIPReputation',
},
},
],
customResponseBodies: {
RateLimitExceeded: {
contentType: 'APPLICATION_JSON',
content: JSON.stringify({
error: 'Rate limit exceeded',
message: 'Too many requests from your IP address. Please try again later.',
}),
},
},
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'LinkShortenerWAF',
},
});
// Enable logging
new wafv2.CfnLoggingConfiguration(this, 'WAFLogging', {
resourceArn: this.webAcl.attrArn,
// logGroupArn ends with ":*", which WAF rejects as a destination
logDestinationConfigs: [Fn.select(0, Fn.split(':*', logGroup.logGroupArn))],
loggingFilter: {
defaultBehavior: 'KEEP',
filters: [
{
behavior: 'DROP',
conditions: [
{
actionCondition: {
action: 'ALLOW',
},
},
],
requirement: 'MEETS_ANY',
},
],
},
});
}
}
Advanced Analytics and Monitoring
Blocking bad actors is half the work. The other half is seeing what happens in your system while it runs:
// lambda/security-monitor.ts
import { CloudWatchClient, PutMetricDataCommand } from '@aws-sdk/client-cloudwatch';
import { SNSClient, PublishCommand } from '@aws-sdk/client-sns';
const cloudwatch = new CloudWatchClient({});
const sns = new SNSClient({});
export interface SecurityEvent {
type: 'RATE_LIMIT_EXCEEDED' | 'MALICIOUS_URL_BLOCKED' | 'SUSPICIOUS_BULK_REQUEST';
userId?: string;
clientIp: string;
userAgent?: string;
details: Record<string, any>;
timestamp: number;
}
export async function recordSecurityEvent(event: SecurityEvent): Promise<void> {
try {
// Send metric to CloudWatch
await cloudwatch.send(new PutMetricDataCommand({
Namespace: 'LinkShortener/Security',
MetricData: [
{
MetricName: event.type,
Value: 1,
Unit: 'Count',
Timestamp: new Date(event.timestamp),
Dimensions: [
{
Name: 'EventType',
Value: event.type,
},
...(event.userId ? [{
Name: 'UserId',
Value: event.userId,
}] : []),
],
},
],
}));
// For critical events, send SNS alert
if (shouldAlertOn(event)) {
await sns.send(new PublishCommand({
TopicArn: process.env.SECURITY_ALERTS_TOPIC_ARN,
Subject: `Security Alert: ${event.type}`,
Message: JSON.stringify({
eventType: event.type,
timestamp: new Date(event.timestamp).toISOString(),
clientIp: event.clientIp,
userId: event.userId,
details: event.details,
}, null, 2),
}));
}
console.log('Security event recorded:', {
type: event.type,
userId: event.userId,
clientIp: event.clientIp,
timestamp: event.timestamp,
});
} catch (error) {
console.error('Failed to record security event:', error);
// Don't throw - security monitoring failures shouldn't break the main flow
}
}
function shouldAlertOn(event: SecurityEvent): boolean {
// Define which events should trigger immediate alerts
const alertEvents: SecurityEvent['type'][] = [
'MALICIOUS_URL_BLOCKED',
'SUSPICIOUS_BULK_REQUEST',
];
return alertEvents.includes(event.type);
}
// Create a dashboard for security metrics
export async function createSecurityDashboard(): Promise<void> {
// This would be part of your CDK infrastructure code
// Implementation depends on your specific monitoring needs
}
Integrating the Security Layers into the API
Here’s how all these security layers come together in a production endpoint:
// lambda/secure-create-url.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { validateUrlSafety } from './url-validator';
import { checkRateLimit, createRateLimitResponse } from './rate-limiter';
import { recordSecurityEvent } from './security-monitor';
import { nanoid } from 'nanoid';
export async function handler(
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> {
const startTime = Date.now();
try {
// Extract user context from authorizer
const userId = event.requestContext.authorizer?.userId;
const planType = event.requestContext.authorizer?.planType || 'free';
const clientIp = event.requestContext.identity?.sourceIp;
if (!userId) {
return {
statusCode: 401,
body: JSON.stringify({ error: 'Authentication required' }),
};
}
// Check rate limits first (fail fast)
const rateLimitCheck = await checkRateLimit(userId, planType, clientIp);
if (!rateLimitCheck.allowed) {
await recordSecurityEvent({
type: 'RATE_LIMIT_EXCEEDED',
userId,
clientIp: clientIp!,
userAgent: event.headers['User-Agent'],
details: { planType, resetTime: rateLimitCheck.resetTime },
timestamp: Date.now(),
});
return createRateLimitResponse(rateLimitCheck.resetTime!);
}
// Parse and validate request
const request = JSON.parse(event.body || '{}');
if (!request.originalUrl) {
return {
statusCode: 400,
body: JSON.stringify({
error: 'originalUrl is required',
remainingRequests: rateLimitCheck.remainingRequests
}),
};
}
// Validate URL safety
const safetyCheck = await validateUrlSafety(request.originalUrl);
if (!safetyCheck.isValid) {
await recordSecurityEvent({
type: 'MALICIOUS_URL_BLOCKED',
userId,
clientIp: clientIp!,
userAgent: event.headers['User-Agent'],
details: {
originalUrl: request.originalUrl,
reason: safetyCheck.reason
},
timestamp: Date.now(),
});
return {
statusCode: 400,
body: JSON.stringify({
error: 'URL validation failed',
reason: safetyCheck.reason,
remainingRequests: rateLimitCheck.remainingRequests
}),
};
}
// Create short URL
const shortCode = request.customSlug || nanoid(8);
// TODO: Save to DynamoDB (implementation from previous parts)
const shortUrl = await createShortUrl({
shortCode,
originalUrl: request.originalUrl,
userId,
expiresAt: request.expiresAt,
tags: request.tags || [],
});
const responseTime = Date.now() - startTime;
// Record successful creation
console.log(`URL created successfully: ${shortCode} -> ${request.originalUrl} (${responseTime}ms)`);
return {
statusCode: 201,
headers: {
'X-RateLimit-Remaining': rateLimitCheck.remainingRequests?.toString() || '0',
'X-Response-Time': responseTime.toString(),
},
body: JSON.stringify({
shortCode,
shortUrl: `${process.env.DOMAIN_NAME}/${shortCode}`,
originalUrl: request.originalUrl,
createdAt: new Date().toISOString(),
expiresAt: request.expiresAt,
remainingRequests: rateLimitCheck.remainingRequests,
}),
};
} catch (error) {
console.error('Error creating short URL:', error);
const responseTime = Date.now() - startTime;
return {
statusCode: 500,
headers: {
'X-Response-Time': responseTime.toString(),
},
body: JSON.stringify({
error: 'Internal server error',
requestId: event.requestContext.requestId
}),
};
}
}
Key Security Considerations
When implementing production-ready link shortener security, several critical factors require careful attention:
1. Security-First Architecture Designing security measures from the beginning is more effective than retrofitting them later. Early security integration prevents architectural conflicts and reduces technical debt during scaling.
2. Serverless Rate Limiting Challenges Traditional token bucket algorithms don’t translate well to serverless environments due to statelessness between invocations. DynamoDB atomic counters with time-based windows provide better serverless rate limiting. Grouping the windows into a single transaction keeps them consistent and doubles the write capacity each check consumes, so those units require monitoring.
3. Adaptive URL Validation Malicious domain lists require constant updates as threat actors register new domains. Building systems that support rapid blocklist updates is more sustainable than attempting comprehensive initial coverage.
4. Pattern-Based Monitoring Individual security events often provide limited insight. Monitoring systems should focus on detecting behavioral patterns: repeated requests from single IPs, unusual redirect volumes, or bulk operations from recently created accounts.
5. Custom Domain Planning Branded short URLs typically become requirements as services mature. Implementing custom domain support during initial development simplifies later expansion compared to retrofitting existing systems.
Next Steps
The four layers above assume a single-region API. Two of them change if you go multi-region: the rate-limit counters move to a DynamoDB global table, where conditional writes stop being atomic across regions, and every region you deploy to needs its own REGIONAL web ACL.
Part 4 covers production deployment strategies, monitoring that helps debug real issues, and cost optimization for the stack built so far. It also goes into traffic spike handling, database scaling patterns, and monitoring configurations that give reliable production visibility.
References
- AWS WAF Developer Guide - Official documentation on configuring web ACLs, rate-based rules, and managed rule groups to protect APIs.
- Custom Domain Names for REST APIs in API Gateway - Guide to setting up custom domain names with ACM certificates for API Gateway endpoints.
- AWS Certificate Manager Overview - Official documentation for provisioning and managing SSL/TLS certificates used with CloudFront and API Gateway.
- Amazon CloudFront Developer Guide - Reference for configuring CDN distributions, caching behaviors, and edge security.
- DynamoDB Global Tables - Documentation on multi-region, multi-active replication for globally distributed tables.
- AWS CDK v2 API Reference - Construct library reference covering ACM, API Gateway, CloudFront, and WAF CDK modules.
AWS CDK Link Shortener: From Zero to Production
A comprehensive 5-part series on building a production-grade link shortener service with AWS CDK, Node.js Lambda, and DynamoDB. Real war stories, performance optimization, and cost management included.
All Posts in This Series
Related posts
A technical guide to advanced Amazon Cognito: custom auth flows, federation, multi-tenancy, migration strategies, and production-grade security with CDK.
A technical guide comparing AWS Secrets Manager and Parameter Store, showing when to use each service with real-world implementation patterns and CDK examples.
Build, secure, and deploy custom Model Context Protocol servers for internal systems in TypeScript, with authentication, monitoring, and Kubernetes deployment.
Implement secure cross-account event distribution with Amazon SNS and SQS: IAM policies, KMS encryption, AWS CDK, and common production pitfalls.
Set up a production-grade link shortener with AWS CDK, DynamoDB, and Lambda: architecture decisions, project layout, and the schema choices that hold up at scale.