Securing S3 Images with CloudFront and Lambda URL Signing

TL;DR: Architecture for serving private S3 images through CloudFront with Lambda-based dynamic URL signing and DynamoDB hash storage

Serving user-uploaded images securely requires preventing unauthorized access while maintaining performance. This architecture uses CloudFront distribution with Lambda@Edge to serve private S3 content through time-limited signed URLs.

Architecture Overview

┌─────────────────────────────────────────────────────────────────┐
│                  S3 IMAGE SECURITY ARCHITECTURE                  │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  Client                                                          │
│     │                                                            │
│     │ 1. Request image                                           │
│     │    /images/abc123hash                                      │
│     ▼                                                            │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │                    CloudFront                            │    │
│  │              (CDN Distribution)                          │    │
│  └─────────────────────────┬───────────────────────────────┘    │
│                            │                                     │
│                            │ 2. Origin request                   │
│                            │    triggers Lambda                  │
│                            ▼                                     │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │                Lambda@Edge                               │    │
│  │           (URL Validation & Signing)                     │    │
│  └────────────┬────────────────────────────┬───────────────┘    │
│               │                            │                     │
│   3. Lookup   │                            │ 4. Return           │
│      hash     │                            │    signed URL       │
│               ▼                            │                     │
│  ┌─────────────────────┐                   │                     │
│  │     DynamoDB        │                   │                     │
│  │  (Hash → S3 URL     │                   │                     │
│  │   Mapping + TTL)    │                   │                     │
│  └─────────────────────┘                   │                     │
│                                            │                     │
│               ┌────────────────────────────┘                     │
│               │                                                  │
│               │ 5. Fetch image with signed URL                   │
│               ▼                                                  │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │              S3 Bucket (Private)                         │    │
│  │           Block all public access                        │    │
│  └─────────────────────────────────────────────────────────┘    │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Security Flow

Step-by-Step Process

  1. Image Upload: App uploads image to private S3 bucket
  2. Hash Generation: Generate unique hash for the S3 URL
  3. Store Mapping: Save hash → S3 URL in DynamoDB with 30-day TTL
  4. Serve Hash URL: Return CloudFront URL with hash to client
  5. Request Validation: Lambda validates hash and returns signed S3 URL
  6. Image Delivery: CloudFront serves image from S3

Hash Mapping Schema

{
  "hash": "abc123def456",
  "s3_url": "s3://bucket-name/images/user/123/profile.jpg",
  "created_at": 1664150400,
  "expires_at": 1666742400,
  "ttl": 1666742400
}

Service-Specific Security Requirements

ServiceContent TypeSecurity LevelImplementation
DiscussionPrivate forumsRequiredImageKit + CloudFront
MarketplaceOpen listingsPublicNo signing needed
Behavioural nudgesPromotionalPublicNo signing needed
Regulatory noticeUpdatesSecuredAlready implemented
Simple chatDirect messagesRequiredMedia service signing

Implementation Components

S3 Bucket Policy

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "CloudFrontAccess",
      "Effect": "Allow",
      "Principal": {
        "Service": "cloudfront.amazonaws.com"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::images-bucket/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID"
        }
      }
    }
  ]
}

DynamoDB Table Configuration

TableName: image-url-mappings
KeySchema:
  - AttributeName: hash
    KeyType: HASH
AttributeDefinitions:
  - AttributeName: hash
    AttributeType: S
TimeToLiveSpecification:
  AttributeName: ttl
  Enabled: true

Lambda@Edge Function

const AWS = require('aws-sdk');
const dynamodb = new AWS.DynamoDB.DocumentClient();

exports.handler = async (event) => {
  const request = event.Records[0].cf.request;
  const hash = request.uri.split('/').pop();
  
  try {
    // Lookup hash in DynamoDB
    const result = await dynamodb.get({
      TableName: 'image-url-mappings',
      Key: { hash }
    }).promise();
    
    if (!result.Item) {
      return {
        status: '404',
        statusDescription: 'Not Found'
      };
    }
    
    // Check expiration
    if (result.Item.expires_at < Date.now() / 1000) {
      return {
        status: '403',
        statusDescription: 'URL Expired'
      };
    }
    
    // Generate signed URL for S3
    const s3 = new AWS.S3();
    const signedUrl = s3.getSignedUrl('getObject', {
      Bucket: 'images-bucket',
      Key: extractKey(result.Item.s3_url),
      Expires: 300  // 5 minute validity
    });
    
    // Redirect to signed URL
    return {
      status: '302',
      statusDescription: 'Found',
      headers: {
        location: [{ value: signedUrl }]
      }
    };
    
  } catch (error) {
    console.error('Error:', error);
    return {
      status: '500',
      statusDescription: 'Internal Server Error'
    };
  }
};

Hash Generation Service

@Service
public class ImageUrlService {
    
    private final DynamoDbClient dynamoDb;
    private final String tableName = "image-url-mappings";
    
    public String generateSecureUrl(String s3Url) {
        // Check if hash already exists for this S3 URL
        String existingHash = findExistingHash(s3Url);
        if (existingHash != null) {
            return buildCloudFrontUrl(existingHash);
        }
        
        // Generate new hash
        String hash = generateHash(s3Url);
        
        // Calculate expiration (30 days)
        long expiresAt = Instant.now()
            .plus(30, ChronoUnit.DAYS)
            .getEpochSecond();
        
        // Store mapping
        Map<String, AttributeValue> item = Map.of(
            "hash", AttributeValue.builder().s(hash).build(),
            "s3_url", AttributeValue.builder().s(s3Url).build(),
            "created_at", AttributeValue.builder()
                .n(String.valueOf(Instant.now().getEpochSecond())).build(),
            "expires_at", AttributeValue.builder()
                .n(String.valueOf(expiresAt)).build(),
            "ttl", AttributeValue.builder()
                .n(String.valueOf(expiresAt)).build()
        );
        
        dynamoDb.putItem(PutItemRequest.builder()
            .tableName(tableName)
            .item(item)
            .build());
        
        return buildCloudFrontUrl(hash);
    }
    
    private String generateHash(String s3Url) {
        return Hashing.sha256()
            .hashString(s3Url + UUID.randomUUID(), StandardCharsets.UTF_8)
            .toString()
            .substring(0, 32);
    }
    
    private String buildCloudFrontUrl(String hash) {
        return String.format("https://cdn.example.com/images/%s", hash);
    }
}

URL Lifecycle

┌─────────────────────────────────────────────────────────────────┐
│                    URL LIFECYCLE                                 │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  Day 0: Image uploaded                                           │
│     │                                                            │
│     ├── Generate hash: abc123def456                              │
│     ├── Store mapping in DynamoDB (TTL: 30 days)                │
│     └── Return: https://cdn.example.com/images/abc123def456     │
│                                                                  │
│  Day 1-30: Hash valid                                            │
│     │                                                            │
│     ├── Client requests hash URL                                 │
│     ├── Lambda looks up S3 URL from DynamoDB                    │
│     ├── Lambda generates signed S3 URL (5 min validity)         │
│     └── CloudFront serves image                                  │
│                                                                  │
│  Day 31: Hash expires                                            │
│     │                                                            │
│     ├── DynamoDB TTL removes mapping                             │
│     ├── Client requests hash URL                                 │
│     ├── Lambda returns 404                                       │
│     └── App must request new hash                                │
│                                                                  │
│  Day 31+: New hash requested                                     │
│     │                                                            │
│     ├── Generate NEW hash: xyz789ghi012                          │
│     ├── Store new mapping (30 more days)                         │
│     └── Return new CloudFront URL                                │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

CloudFront Configuration

Distribution Settings

CloudFrontDistribution:
  Origins:
    - DomainName: images-bucket.s3.amazonaws.com
      S3OriginConfig:
        OriginAccessIdentity: ''
      OriginAccessControlId: !Ref OAC
  
  DefaultCacheBehavior:
    ViewerProtocolPolicy: redirect-to-https
    AllowedMethods:
      - GET
      - HEAD
    CachedMethods:
      - GET
      - HEAD
    CachePolicyId: !Ref CachePolicy
    LambdaFunctionAssociations:
      - EventType: origin-request
        LambdaFunctionARN: !Ref LambdaEdgeFunction

Cache Policy

CachePolicy:
  ParametersInCacheKeyAndForwardedToOrigin:
    CookiesConfig:
      CookieBehavior: none
    HeadersConfig:
      HeaderBehavior: none
    QueryStringsConfig:
      QueryStringBehavior: none
  DefaultTTL: 86400      # 1 day
  MaxTTL: 2592000        # 30 days
  MinTTL: 0

Security Benefits

AspectProtection
Direct S3 AccessBlocked - bucket is private
URL GuessingHash is cryptographically random
URL SharingExpires after 30 days
Signed URL LeakInner signed URL valid only 5 minutes
Hot-linkingCloudFront validates origin

Monitoring

CloudWatch Metrics

  • Lambda invocation count and duration
  • Cache hit/miss ratio
  • 4xx/5xx error rates
  • DynamoDB read capacity usage

Alerting

Alarms:
  - HighErrorRate:
      Metric: 5xxErrorRate
      Threshold: 5%
      Period: 300
  - HighLatency:
      Metric: LambdaDuration
      Threshold: 500ms
      Period: 300

This architecture ensures images remain private while delivering them efficiently through CloudFront’s global edge network, with automatic expiration preventing indefinite access to sensitive content.