Skip to content

AWS CDK Link Shortener Part 3: Advanced Features & Security

Implementing custom domains, bulk operations, URL expiration, and comprehensive security measures. Defense-in-depth protection strategies for production link shortener services.

AWS CDK Link Shortener Part 3: Advanced Features & Security

Building a production link shortener requires more than just creating short URLs - it demands comprehensive security measures that can handle legitimate scale while preventing abuse. Link shorteners are attractive targets for malicious actors who exploit them to distribute harmful content, bypass security filters, and conduct 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 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.tsimport { 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: Always create your ACM certificate in us-east-1 for API Gateway edge-optimized endpoints, regardless of where your other resources are deployed. API Gateway edge-optimized endpoints require certificates to be in the us-east-1 region specifically.

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.tsimport { 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:

typescript
// lambda/redirect-with-expiration.tsimport { 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.tsimport { 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 abusedconst 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.tsimport { 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.tsimport { 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.tsimport { 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.tsimport { 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 metricsexport 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.tsimport { 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, though write capacity 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.

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 explore operational considerations including traffic spike handling, database scaling patterns, and monitoring configurations that provide reliable production visibility.

The security foundation established here supports scaling to handle significant traffic volumes while maintaining protection against evolving threats. Effective deployment pipelines and comprehensive monitoring ensure these security measures remain effective at scale.


Current AWS Pricing Benefits

With recent AWS pricing updates (2024-2025), combining CloudFront with WAF has become more cost-effective:

  • CloudFront pricing reduction: Up to 25% cost savings on data transfer
  • WAF integration: No additional charges for CloudFront-WAF association
  • Regional optimization: WAF pricing varies by region, with us-east-1 typically offering the lowest rates
  • Request filtering: WAF blocks malicious requests before they reach your Lambda functions, reducing compute costs

These improvements make implementing comprehensive security layers more economical for production link shortener services.

References

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

Related Posts