aryem.dev

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:

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:

After optimization:

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:

Performance:

Compliance:

Migration Strategy

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

Week 1: Deploy infrastructure (no breaking changes)

Week 2: Client updates

Week 3: Hybrid mode

Week 4: Full migration

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:

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.