AWS CDK Link Shortener Part 3: Advanced Features & Security

Implementing custom domains, bulk operations, URL expiration, and comprehensive security measures. Real production security incidents and how we built defense-in-depth protection.

AWS CDK Link Shortener Part 3: Advanced Features & Security#

Picture this: It's the week before Black Friday, and our marketing team just uploaded 50,000 product links for their campaign. Everything looked great until Monday morning when our security alerts went off. Someone had discovered our bulk upload API and was using it to redirect traffic to... let's just say "inappropriate content."

That incident taught me that building a production link shortener isn't just about creating short URLs—it's about building a fortress that can handle legitimate scale while keeping the bad actors out. After cleaning up that mess (and several uncomfortable conversations with our compliance team), we rebuilt our service with proper security layers.

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 Short Domains: More Than Just Vanity URLs#

Before we dive into security, let's tackle custom domains. 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:

TypeScript
// 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)
      ),
    });
  }
}

Pro tip from the trenches: Always create your ACM certificate in us-east-1 for API Gateway edge-optimized endpoints, regardless of where your other resources live. I spent two hours debugging why my certificate "didn't exist" before realizing this requirement.

Bulk Operations: Handling Scale Gracefully#

Marketing teams love bulk operations. Here's a production-tested implementation that won't blow up your Lambda concurrency limits:

TypeScript
// 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 (learned this the hard way)
    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:

TypeScript
// 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';

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#

Now for the meat of this post. Security isn't an afterthought—it's what keeps your service from becoming a malware distribution platform. Here's our layered security approach:

Layer 1: Input Validation and URL Safety#

TypeScript
// 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([
  // 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#

TypeScript
// 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#

TypeScript
// lambda/rate-limiter.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient, UpdateItemCommand, GetItemCommand } from '@aws-sdk/client-dynamodb';
import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';

const dynamodb = new DynamoDBClient({});

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,
  },
};

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();
  
  // Create time windows
  const minuteWindow = Math.floor(now / (60 * 1000));
  const hourWindow = Math.floor(now / (60 * 60 * 1000));
  const dayWindow = Math.floor(now / (24 * 60 * 60 * 1000));

  try {
    // Check and update rate limits atomically
    const updateResult = await dynamodb.send(new UpdateItemCommand({
      TableName: process.env.RATE_LIMIT_TABLE_NAME,
      Key: marshall({
        userId,
        window: 'COMBINED'
      }),
      UpdateExpression: `
        SET 
          #minute = if_not_exists(#minute, :zero),
          #hour = if_not_exists(#hour, :zero),
          #day = if_not_exists(#day, :zero),
          #minuteWindow = if_not_exists(#minuteWindow, :currentMinute),
          #hourWindow = if_not_exists(#hourWindow, :currentHour),
          #dayWindow = if_not_exists(#dayWindow, :currentDay)
        ADD 
          #minute :inc,
          #hour :inc,
          #day :inc
      `,
      ConditionExpression: `
        (attribute_not_exists(#minuteWindow) OR #minuteWindow = :currentMinute OR #minute < :minuteLimit) AND
        (attribute_not_exists(#hourWindow) OR #hourWindow = :currentHour OR #hour < :hourLimit) AND
        (attribute_not_exists(#dayWindow) OR #dayWindow = :currentDay OR #day < :dayLimit)
      `,
      ExpressionAttributeNames: {
        '#minute': 'requestsThisMinute',
        '#hour': 'requestsThisHour',
        '#day': 'requestsThisDay',
        '#minuteWindow': 'minuteWindow',
        '#hourWindow': 'hourWindow',
        '#dayWindow': 'dayWindow',
      },
      ExpressionAttributeValues: marshall({
        ':zero': 0,
        ':inc': 1,
        ':currentMinute': minuteWindow,
        ':currentHour': hourWindow,
        ':currentDay': dayWindow,
        ':minuteLimit': config.requestsPerMinute,
        ':hourLimit': config.requestsPerHour,
        ':dayLimit': config.requestsPerDay,
      }),
      ReturnValues: 'ALL_NEW',
    }));

    const item = unmarshall(updateResult.Attributes!);
    
    return {
      allowed: true,
      remainingRequests: Math.min(
        config.requestsPerMinute - item.requestsThisMinute,
        config.requestsPerHour - item.requestsThisHour,
        config.requestsPerDay - item.requestsThisDay
      ),
    };

  } catch (error: any) {
    if (error.name === 'ConditionalCheckFailedException') {
      // Rate limit exceeded
      const getResult = await dynamodb.send(new GetItemCommand({
        TableName: process.env.RATE_LIMIT_TABLE_NAME,
        Key: marshall({ userId, window: 'COMBINED' }),
      }));

      if (getResult.Item) {
        const item = unmarshall(getResult.Item);
        
        // Calculate reset time based on which limit was hit
        let resetTime = now + (60 * 1000); // Default to 1 minute
        
        if (item.requestsThisDay >= config.requestsPerDay) {
          resetTime = (dayWindow + 1) * 24 * 60 * 60 * 1000;
        } else if (item.requestsThisHour >= config.requestsPerHour) {
          resetTime = (hourWindow + 1) * 60 * 60 * 1000;
        }

        return {
          allowed: false,
          resetTime,
          remainingRequests: 0,
        };
      }
    }

    throw error;
  }
}

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#

TypeScript
// lib/waf-stack.ts
import { 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
    const logGroup = new logs.LogGroup(this, 'WAFLogGroup', {
      logGroupName: `/aws/wafv2/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',
          },
        },

        // Rule 5: Custom geo-blocking (if needed)
        {
          name: 'GeoBlocking',
          priority: 5,
          statement: {
            geoMatchStatement: {
              // Block requests from specific countries if needed
              countryCodes: [], // Add country codes to block
            },
          },
          action: { block: {} },
          visibilityConfig: {
            sampledRequestsEnabled: true,
            cloudWatchMetricsEnabled: true,
            metricName: 'GeoBlocking',
          },
        },
      ],

      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,
      logDestinationConfigs: [logGroup.logGroupArn],
      loggingFilter: {
        defaultBehavior: 'KEEP',
        filters: [
          {
            behavior: 'DROP',
            conditions: [
              {
                actionCondition: {
                  action: 'ALLOW',
                },
              },
            ],
            requirement: 'MEETS_ANY',
          },
        ],
      },
    });
  }
}

Advanced Analytics and Monitoring#

Security isn't just about blocking bad actors—it's about understanding what's happening in your system:

TypeScript
// 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
}

Putting It All Together: The Security-First API#

Here's how all these security layers come together in a production endpoint:

TypeScript
// 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 
      }),
    };
  }
}

Lessons Learned: What I Wish I'd Known Earlier#

After running this in production for two years, here are the hard-won insights:

1. Start with security, not performance We originally focused on making redirects fast, then bolted on security later. Big mistake. Rebuilding with security-first thinking would have saved us weeks of refactoring and several embarrassing incidents.

2. Rate limiting is harder than it looks Simple token bucket algorithms don't work well with serverless because you lose state between invocations. DynamoDB atomic counters with time windows work better, but watch your write capacity units.

3. URL validation is never complete No matter how comprehensive your malicious URL detection is, attackers will find new domains. Build a system that can be updated quickly rather than trying to be perfect from day one.

4. Monitor everything, alert on patterns Single security events are usually not interesting. Patterns are what matter. Build your monitoring to detect trends: same IP creating many URLs, unusual redirect patterns, bulk operations from new accounts.

5. Custom domains are worth the complexity Marketing teams will ask for branded short URLs eventually. Building custom domain support early is easier than retrofitting it later. The SSL certificate dance is annoying but manageable.

What's Next?#

In Part 4, we'll cover production deployment strategies, monitoring that actually helps debug issues, and cost optimization techniques that can save you hundreds of dollars per month.

We'll also dive into the operational aspects: how to handle traffic spikes, database scaling patterns, and the monitoring setup that helps you sleep soundly knowing your link shortener won't become the next security incident in your incident log.

The security foundation we built here will serve us well as we scale up to handle millions of redirects per day. But first, we need to make sure our deployment pipeline and monitoring can keep up.


Have war stories about link shortener security incidents? I'd love to hear them. The creativity of attackers never ceases to amaze me, and sharing these stories helps all of us build better defenses.

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.

Progress3/5 posts completed
Loading...

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!

Related Posts