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:
- Predictable URLs: Sequential IDs made enumeration trivial
- No authorization: Anyone with a link could access content
- No expiration: Links worked forever
- 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.pemUse 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 latter4. 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 playbackPerformance 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
- Security should be built-in, not bolted-on: Retrofitting security is expensive
- Signed URLs are table stakes: For any SaaS with user content
- Performance matters: Cache aggressively
- Audit everything: You’ll need logs for compliance and debugging
- 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.