Securing Media Assets at Scale: Implementing Signed URLs and Authorization

A failed penetration test. That’s how we discovered our media assets were completely exposed to the internet. Anyone with a URL could access any user’s content - images, videos, audio files. For an enterprise SaaS product, this was a critical vulnerability.

We had 48 hours to fix it before losing a major enterprise contract. Here’s how we implemented signed URLs and proper authorization, passed the re-test, and secured that deal.

The Vulnerability

Our initial architecture was naive:

User uploads media
    ↓
[S3 Bucket] (public read)
    ↓
[CloudFront CDN]
    ↓
Public URL: https://cdn.example.com/media/user-123/video.mp4

Problems:

  1. Predictable URLs: Sequential IDs made enumeration trivial
  2. No authorization: Anyone with a link could access content
  3. No expiration: Links worked forever
  4. No audit trail: Couldn’t track who accessed what

The penetration testers automated scraping of thousands of user files in minutes.

The Fix: Signed URLs + Authorization Layer

We implemented a multi-layered security approach:

[Client Request]
    ↓
[API Gateway] → Verify user authentication
    ↓
[Authorization Service] → Check permissions
    ↓
[Signed URL Generator] → Create time-limited URL
    ↓
[CloudFront] → Validate signature
    ↓
[S3 Private Bucket] → Serve content

Implementation

1. Make S3 Buckets Private

First, lock down direct access:

// Terraform configuration
resource "aws_s3_bucket" "media" {
  bucket = "course-media-private"
 
  // Block ALL public access
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}
 
resource "aws_s3_bucket_public_access_block" "media" {
  bucket = aws_s3_bucket.media.id
 
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}
 
// Only CloudFront can access
resource "aws_s3_bucket_policy" "media" {
  bucket = aws_s3_bucket.media.id
 
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = {
        Service = "cloudfront.amazonaws.com"
      }
      Action   = "s3:GetObject"
      Resource = "${aws_s3_bucket.media.arn}/*"
      Condition = {
        StringEquals = {
          "AWS:SourceArn" = aws_cloudfront_distribution.media.arn
        }
      }
    }]
  })
}

2. Configure CloudFront for Signed URLs

resource "aws_cloudfront_distribution" "media" {
  enabled = true
 
  origin {
    domain_name = aws_s3_bucket.media.bucket_regional_domain_name
    origin_id   = "S3-media"
 
    origin_access_control_id = aws_cloudfront_origin_access_control.media.id
  }
 
  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = "S3-media"
    viewer_protocol_policy = "redirect-to-https"
 
    // Require signed URLs
    trusted_key_groups = [aws_cloudfront_key_group.media.id]
 
    min_ttl     = 0
    default_ttl = 3600
    max_ttl     = 86400
  }
}
 
resource "aws_cloudfront_key_group" "media" {
  name = "media-key-group"
  items = [aws_cloudfront_public_key.media.id]
}
 
resource "aws_cloudfront_public_key" "media" {
  name        = "media-signing-key"
  encoded_key = file("cloudfront-public-key.pem")
}

3. Authorization Service

Check if user has permission to access resource:

interface MediaAsset {
  id: string;
  userId: string;
  courseId: string;
  fileName: string;
  s3Key: string;
}
 
class MediaAuthorizationService {
  async canAccessMedia(
    requestingUserId: string,
    assetId: string
  ): Promise<boolean> {
    const asset = await this.db.mediaAssets.findById(assetId);
    if (!asset) return false;
 
    // Owner can always access
    if (asset.userId === requestingUserId) return true;
 
    // Check if user has access to the course
    const course = await this.db.courses.findById(asset.courseId);
    if (!course) return false;
 
    // Check various permission scenarios
    return (
      course.ownerId === requestingUserId ||
      await this.isCoAuthor(requestingUserId, asset.courseId) ||
      await this.hasSharedAccess(requestingUserId, asset.courseId) ||
      await this.isEnrolled(requestingUserId, asset.courseId)
    );
  }
 
  private async isCoAuthor(
    userId: string,
    courseId: string
  ): Promise<boolean> {
    const permissions = await this.db.coursePermissions.findOne({
      userId,
      courseId,
      role: 'author'
    });
    return !!permissions;
  }
 
  private async hasSharedAccess(
    userId: string,
    courseId: string
  ): Promise<boolean> {
    const share = await this.db.courseShares.findOne({
      sharedWithUserId: userId,
      courseId,
      expiresAt: { $gt: new Date() }
    });
    return !!share;
  }
 
  private async isEnrolled(
    userId: string,
    courseId: string
  ): Promise<boolean> {
    const enrollment = await this.db.enrollments.findOne({
      userId,
      courseId,
      status: 'active'
    });
    return !!enrollment;
  }
}

4. Signed URL Generator

Generate time-limited, cryptographically signed URLs:

import { getSignedUrl } from '@aws-sdk/cloudfront-signer';
import { readFileSync } from 'fs';
 
class SignedUrlService {
  private readonly cloudfrontDomain = 'https://d123456.cloudfront.net';
  private readonly keyPairId = 'K2JCJMDEHXQW5F';
  private readonly privateKey: string;
 
  constructor() {
    this.privateKey = readFileSync('cloudfront-private-key.pem', 'utf8');
  }
 
  generateSignedUrl(
    s3Key: string,
    expiresIn: number = 3600 // 1 hour default
  ): string {
    const url = `${this.cloudfrontDomain}/${s3Key}`;
    const expiresAt = new Date(Date.now() + expiresIn * 1000);
 
    return getSignedUrl({
      url,
      keyPairId: this.keyPairId,
      privateKey: this.privateKey,
      dateLessThan: expiresAt.toISOString(),
    });
  }
 
  generateSignedUrlWithPolicy(
    s3Key: string,
    options: {
      expiresIn?: number;
      ipAddress?: string;
      startTime?: Date;
    } = {}
  ): string {
    const url = `${this.cloudfrontDomain}/${s3Key}`;
    const expiresAt = new Date(
      Date.now() + (options.expiresIn || 3600) * 1000
    );
 
    const policy = {
      Statement: [{
        Resource: url,
        Condition: {
          DateLessThan: {
            'AWS:EpochTime': Math.floor(expiresAt.getTime() / 1000)
          },
          ...(options.startTime && {
            DateGreaterThan: {
              'AWS:EpochTime': Math.floor(options.startTime.getTime() / 1000)
            }
          }),
          ...(options.ipAddress && {
            IpAddress: {
              'AWS:SourceIp': options.ipAddress
            }
          })
        }
      }]
    };
 
    return getSignedUrl({
      url,
      keyPairId: this.keyPairId,
      privateKey: this.privateKey,
      policy: JSON.stringify(policy),
    });
  }
}

5. API Endpoint

Tie it all together:

import { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
 
const authService = new MediaAuthorizationService();
const signedUrlService = new SignedUrlService();
 
app.get(
  '/api/media/:assetId/url',
  authenticate,
  async (req: Request, res: Response) => {
    const { assetId } = req.params;
    const userId = req.user.id;
 
    try {
      // 1. Check authorization
      const canAccess = await authService.canAccessMedia(userId, assetId);
      if (!canAccess) {
        return res.status(403).json({
          error: 'You do not have permission to access this media'
        });
      }
 
      // 2. Get asset details
      const asset = await db.mediaAssets.findById(assetId);
      if (!asset) {
        return res.status(404).json({ error: 'Media not found' });
      }
 
      // 3. Generate signed URL
      const signedUrl = signedUrlService.generateSignedUrl(
        asset.s3Key,
        3600 // 1 hour expiration
      );
 
      // 4. Audit log
      await db.mediaAccessLogs.create({
        userId,
        assetId,
        accessedAt: new Date(),
        ipAddress: req.ip,
        userAgent: req.get('user-agent')
      });
 
      // 5. Return signed URL
      res.json({
        url: signedUrl,
        expiresIn: 3600
      });
 
    } catch (error) {
      console.error('Error generating signed URL:', error);
      res.status(500).json({ error: 'Internal server error' });
    }
  }
);

6. Client-Side Usage

Frontend requests signed URL before displaying media:

// React component
function VideoPlayer({ assetId }: { assetId: string }) {
  const [videoUrl, setVideoUrl] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    async function fetchSignedUrl() {
      try {
        const response = await fetch(`/api/media/${assetId}/url`, {
          headers: {
            'Authorization': `Bearer ${getAuthToken()}`
          }
        });
 
        if (!response.ok) {
          throw new Error('Failed to fetch media URL');
        }
 
        const data = await response.json();
        setVideoUrl(data.url);
 
        // Refresh URL before it expires
        const refreshTimer = setTimeout(
          () => fetchSignedUrl(),
          (data.expiresIn - 300) * 1000 // Refresh 5 min before expiry
        );
 
        return () => clearTimeout(refreshTimer);
      } catch (err) {
        setError(err.message);
      }
    }
 
    fetchSignedUrl();
  }, [assetId]);
 
  if (error) return <div>Error loading video: {error}</div>;
  if (!videoUrl) return <div>Loading...</div>;
 
  return <video src={videoUrl} controls />;
}

Advanced Features

URL Caching Strategy

Signed URLs can be cached temporarily:

class CachedSignedUrlService {
  private cache = new Map<string, { url: string; expiresAt: Date }>();
 
  async getSignedUrl(assetId: string, userId: string): Promise<string> {
    const cacheKey = `${userId}:${assetId}`;
    const cached = this.cache.get(cacheKey);
 
    // Return cached if still valid (with 5 min buffer)
    if (cached && cached.expiresAt > new Date(Date.now() + 5 * 60 * 1000)) {
      return cached.url;
    }
 
    // Generate new signed URL
    const asset = await db.mediaAssets.findById(assetId);
    const signedUrl = signedUrlService.generateSignedUrl(asset.s3Key);
 
    // Cache it
    this.cache.set(cacheKey, {
      url: signedUrl,
      expiresAt: new Date(Date.now() + 3600 * 1000)
    });
 
    return signedUrl;
  }
}

Rate Limiting

Prevent abuse:

import rateLimit from 'express-rate-limit';
 
const mediaRateLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // Limit each user to 100 requests per window
  keyGenerator: (req) => req.user.id,
  message: 'Too many media requests, please try again later'
});
 
app.get(
  '/api/media/:assetId/url',
  authenticate,
  mediaRateLimiter,
  async (req, res) => { /* ... */ }
);

Watermarking

Add dynamic watermarks for sensitive content:

async function generateWatermarkedUrl(
  asset: MediaAsset,
  userId: string
): Promise<string> {
  const user = await db.users.findById(userId);
 
  // For images, generate watermarked version
  if (asset.type === 'image') {
    const watermarkedKey = await addWatermark(asset.s3Key, {
      text: `${user.email} - ${new Date().toISOString()}`,
      opacity: 0.3
    });
    return signedUrlService.generateSignedUrl(watermarkedKey);
  }
 
  // For videos, append query params for player-side watermark
  return signedUrlService.generateSignedUrl(asset.s3Key);
}

Security Considerations

1. Key Management

Don’t commit private keys to git:

# .gitignore
cloudfront-private-key.pem
cloudfront-public-key.pem

Use AWS Secrets Manager or environment variables:

import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
 
async function getPrivateKey(): Promise<string> {
  const client = new SecretsManagerClient({ region: 'us-east-1' });
  const response = await client.send(
    new GetSecretValueCommand({ SecretId: 'cloudfront-private-key' })
  );
  return response.SecretString!;
}

2. URL Expiration Times

Choose appropriate expiration:

  • Short-lived content (1 hour): Videos, images in lessons
  • Download links (24 hours): Course exports, backups
  • Embedded media (7 days): Emails, external embeds

3. Audit Logging

Track all access:

interface MediaAccessLog {
  userId: string;
  assetId: string;
  accessedAt: Date;
  ipAddress: string;
  userAgent: string;
  signedUrlGenerated: boolean;
  actualDownload: boolean;
}
 
// Log both URL generation AND actual CloudFront access
// Use CloudFront access logs for the latter

4. Prevent URL Sharing

Signed URLs can still be shared. Additional measures:

// Bind URL to IP address
const signedUrl = signedUrlService.generateSignedUrlWithPolicy(
  asset.s3Key,
  { ipAddress: req.ip }
);
 
// Bind to user agent (less secure, but adds friction)
// Implement on client side by checking user agent before playback

Performance Impact

Before optimization:

  • URL generation: 150ms (database + crypto)
  • Added latency for every media request

After optimization:

  • In-memory cache for authorization decisions
  • Redis cache for signed URLs
  • Result: 15ms average latency
import Redis from 'ioredis';
const redis = new Redis();
 
async function canAccessMediaCached(
  userId: string,
  assetId: string
): Promise<boolean> {
  const cacheKey = `auth:${userId}:${assetId}`;
  const cached = await redis.get(cacheKey);
 
  if (cached !== null) {
    return cached === '1';
  }
 
  const canAccess = await authService.canAccessMedia(userId, assetId);
 
  // Cache for 5 minutes
  await redis.setex(cacheKey, 300, canAccess ? '1' : '0');
 
  return canAccess;
}

Results

Security improvements:

  • ✅ Passed penetration test
  • ✅ Secured enterprise contract ($500K ARR)
  • ✅ Zero unauthorized access incidents in 2 years

Performance:

  • Average signed URL generation: 15ms
  • Cache hit rate: 85%
  • CDN cache hit rate: 92%

Compliance:

  • Met SOC 2 requirements
  • GDPR compliant (audit trail + access control)
  • HIPAA ready (with additional encryption)

Migration Strategy

We couldn’t break existing URLs overnight. Phased approach:

Week 1: Deploy infrastructure (no breaking changes)

  • CloudFront signed URL support enabled
  • API endpoints deployed
  • Old public URLs still work

Week 2: Client updates

  • Update apps to request signed URLs
  • Monitor adoption via feature flags

Week 3: Hybrid mode

  • New uploads use private bucket
  • Old assets remain public (monitored)

Week 4: Full migration

  • Script to move old assets to private bucket
  • Public bucket access removed
  • Monitor for breakage

Zero customer impact.

Lessons Learned

  1. Security should be built-in, not bolted-on: Retrofitting security is expensive
  2. Signed URLs are table stakes: For any SaaS with user content
  3. Performance matters: Cache aggressively
  4. Audit everything: You’ll need logs for compliance and debugging
  5. Plan for migration: Phased rollouts prevent disasters

Conclusion

Securing media assets with signed URLs and proper authorization isn’t optional - it’s critical for enterprise SaaS. The implementation is straightforward with AWS CloudFront, but success requires:

  • Multi-layered authorization (not just signed URLs)
  • Thoughtful caching for performance
  • Comprehensive audit logging
  • Careful migration planning

This security overhaul was a decisive factor in passing our pen test and closing our largest enterprise deal.


Implementing security features in your SaaS? I’d love to discuss architecture strategies. Connect on LinkedIn.