The Problem That Started Everything
A month ago, I was architecting a file service for our microservices platform. The requirements seemed straightforward: multiple client applications needed to upload files, different file types with varying size limits, no AWS credentials should ever leave our backend, sub-100ms response times for URL generation, and the ability to handle 50,000+ uploads per hour during peak traffic.
I immediately knew the traditional proxy approach was wrong—having clients upload to our backend, which then proxies everything to S3, would create obvious bottlenecks. The backend becomes a single point of failure, bandwidth costs double, latency increases for large files, and server resources get wasted on proxying.
I had heard about pre-signed URLs as the elegant solution—direct client-to-S3 uploads without exposing credentials. Perfect! But as I started implementing them, I realized I had no idea how they actually worked under the hood.
I expected my backend to be calling some AWS API to generate these URLs, maybe something like
createPreSignedUrl()
. But as I dug deeper into the documentation and started experimenting, I
discovered something that absolutely blew my mind: the AWS SDK generates these URLs entirely on my
machine, using only my AWS credentials and some cryptographic magic.
There was no API call to AWS. No network request. No validation step. The SDK was creating these URLs locally and somehow AWS would just... trust them?
This fundamental realization kept me up at night: How can AWS trust a URL that was generated entirely on my machine, without ever validating it with their servers?
I knew pre-signed URLs were the right architectural choice, but I needed to understand this magic. The answer I discovered reveals one of the most elegant distributed authorization systems ever built.
Understanding S3's Architectural Philosophy
To understand pre-signed URLs, you need to understand how Amazon designed S3 from the ground up.
The RESTful Foundation
S3 was built on a simple principle: every object is addressable via HTTP. When AWS launched S3 in 2006, they made a crucial architectural decision:
https://bucket-name.s3.amazonaws.com/object-key
This wasn't just a URL scheme—it was a commitment to HTTP semantics:
GET
retrieves objectsPUT
uploads objectsDELETE
removes objectsHEAD
gets metadata- Standard HTTP status codes
- Standard HTTP headers
The Authentication Challenge
But HTTP alone isn't secure. AWS needed a way to:
- Authenticate requests (prove who you are)
- Authorize operations (prove what you can do)
- Scale to billions of requests per day
- Maintain sub-millisecond latency
Their solution was revolutionary: embed authentication directly into the HTTP request.
How Pre-Signed URLs Actually Work
The Cryptographic Foundation
Pre-signed URLs use AWS Signature Version 4 (SigV4), which is based on HMAC-SHA256. Here's what happens when you generate a pre-signed URL:
// Simplified version of what the AWS SDK does internally
function createPresignedUrl(credentials, request, expiresIn) {
// 1. Create canonical request
const canonicalRequest = createCanonicalRequest(request);
// 2. Create string to sign
const stringToSign = createStringToSign(canonicalRequest, credentials.region);
// 3. Calculate signature
const signature = calculateSignature(stringToSign, credentials.secretKey);
// 4. Append signature to URL
return `${request.url}?${signature}&X-Amz-Expires=${expiresIn}`;
}
The Canonical Request Format
The canonical request includes every detail that AWS needs to validate:
PUT
/my-bucket/upload/user-123/profile.jpg
host=my-bucket.s3.amazonaws.com&x-amz-date=20250609T120000Z
host:my-bucket.s3.amazonaws.com
x-amz-content-sha256:UNSIGNED-PAYLOAD
x-amz-date:20250609T120000Z
host;x-amz-content-sha256;x-amz-date
UNSIGNED-PAYLOAD
Key insight: This canonical format ensures that any modification to the request (different path, different headers, different method) will invalidate the signature.
The Trust Model
When S3 receives a request with a pre-signed URL:
- Extracts the signature and metadata from query parameters
- Reconstructs the canonical request from the actual HTTP request
- Calculates what the signature should be using the same algorithm
- Compares calculated signature with provided signature
- Allows or denies the request based on the match
Crucially: AWS never needs to store or look up the pre-signed URL. The URL itself contains all the information needed for validation.
Deep Dive: S3's Internal Architecture
Here’s a clean written (markdown-friendly) version of your explanation for "How S3 Scales Authorization"—with diagrams replaced by readable text steps:
How S3 Scales Authorization
Traditional authorization systems look like this:
- Request is received
- It goes to the Auth Service
- Auth Service performs a Database Lookup
- It then runs a Permission Check
- Finally, it returns Allow or Deny
S3's approach:
- Request comes with a Pre-signed URL
- S3 Extracts the Signature from the request
- It then Recalculates the Signature based on expected inputs
- Compares Signatures
- Returns Allow or Deny
Benefits:
- No database lookups required
- Horizontally scalable (stateless)
- Sub-millisecond latency
- Works across all regions without synchronization
The Storage Layer
S3's storage architecture is built on several key principles:
1. Objects are Immutable Once uploaded, an S3 object never changes. Updates create new versions.
2. Metadata is Separate from Data Object metadata is stored in a distributed index, separate from the actual file data.
3. Geographic Replication Objects are automatically replicated across multiple Availability Zones within a region.
4. Eventual Consistency Model S3 provides read-after-write consistency for new objects, eventual consistency for overwrites and deletes.
How This Enables Pre-Signed URLs
The immutable nature of S3 objects means that pre-signed URLs can be cached safely. When you generate a pre-signed URL for a specific object version, that URL will always refer to the same data.
Google Cloud Storage: A Different Philosophy
Google took a fundamentally different approach with Cloud Storage, reflecting their experience with distributed systems at scale.
Service Account-Based Architecture
Instead of HMAC signatures, Google uses RSA private keys:
// Google Cloud Storage approach
async function generateSignedUrl(serviceAccount, bucketName, fileName) {
const options = {
version: "v4",
action: "write",
expires: Date.now() + 15 * 60 * 1000, // 15 minutes
};
const [url] = await storage.bucket(bucketName).file(fileName).getSignedUrl(options);
return url;
}
Key Differences from AWS
Aspect | AWS S3 | Google Cloud Storage |
---|---|---|
Signing Algorithm | HMAC-SHA256 | RSA-SHA256 |
Key Management | AWS manages secrets | You manage private keys |
URL Structure | Query parameters | Query parameters + policy |
Maximum Expiry | 7 days | 7 days |
Offline Generation | Yes (with credentials) | Yes (with private key) |
Google's Policy-Based Approach
Google Cloud Storage signed URLs can include complex policies:
{
"conditions": [
{ "bucket": "my-bucket" },
["starts-with", "$key", "uploads/"],
{ "acl": "bucket-owner-read" },
["content-length-range", 0, 10485760]
]
}
This allows for more granular control but at the cost of complexity.
Performance Characteristics
Google's Advantages:
- Better integration with Google's global network
- More granular access control
- Better audit logging
AWS's Advantages:
- Simpler mental model
- Faster URL generation (symmetric crypto vs asymmetric)
- More mature ecosystem
Implementation Deep-Dive: Building Production-Ready File Service
Let me show you how I built our production file service, including all the edge cases and optimizations.
The Complete NestJS Implementation
// file.service.ts
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { Injectable, BadRequestException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
interface UploadUrlRequest {
fileName: string;
contentType: string;
contentLength: number;
userId: string;
folder?: string;
}
interface UploadUrlResponse {
uploadUrl: string;
downloadUrl: string;
key: string;
expiresAt: Date;
}
@Injectable()
export class FileService {
private readonly s3: S3Client;
private readonly bucketName: string;
private readonly region: string;
private readonly maxFileSize = 100 * 1024 * 1024; // 100MB
constructor(private configService: ConfigService) {
this.bucketName = this.configService.get("AWS_S3_BUCKET");
this.region = this.configService.get("AWS_REGION", "us-east-1");
this.s3 = new S3Client({
region: this.region,
credentials: {
accessKeyId: this.configService.get("AWS_ACCESS_KEY_ID"),
secretAccessKey: this.configService.get("AWS_SECRET_ACCESS_KEY"),
},
});
}
async generateUploadUrl(request: UploadUrlRequest): Promise<UploadUrlResponse> {
// Validation
this.validateUploadRequest(request);
// Generate unique key
const key = this.generateObjectKey(request);
// Create presigned URL for upload
const uploadCommand = new PutObjectCommand({
Bucket: this.bucketName,
Key: key,
ContentType: request.contentType,
ContentLength: request.contentLength,
Metadata: {
"user-id": request.userId,
"original-name": request.fileName,
"upload-timestamp": new Date().toISOString(),
},
});
const expiresIn = 300; // 5 minutes
const uploadUrl = await getSignedUrl(this.s3, uploadCommand, { expiresIn });
// Generate corresponding download URL
const downloadCommand = new GetObjectCommand({
Bucket: this.bucketName,
Key: key,
});
const downloadUrl = await getSignedUrl(this.s3, downloadCommand, {
expiresIn: 3600, // 1 hour
});
return {
uploadUrl,
downloadUrl,
key,
expiresAt: new Date(Date.now() + expiresIn * 1000),
};
}
private validateUploadRequest(request: UploadUrlRequest): void {
// File size validation
if (request.contentLength > this.maxFileSize) {
throw new BadRequestException(
`File size ${request.contentLength} exceeds maximum ${this.maxFileSize}`
);
}
// Content type validation
const allowedTypes = [
"image/jpeg",
"image/png",
"image/gif",
"application/pdf",
"text/plain",
"video/mp4",
"video/quicktime",
];
if (!allowedTypes.includes(request.contentType)) {
throw new BadRequestException(`Content type ${request.contentType} not allowed`);
}
// File name validation
if (!/^[a-zA-Z0-9._-]+$/.test(request.fileName)) {
throw new BadRequestException("Invalid file name format");
}
}
private generateObjectKey(request: UploadUrlRequest): string {
const timestamp = Date.now();
const randomId = Math.random().toString(36).substring(2);
const folder = request.folder || "uploads";
const extension = request.fileName.split(".").pop();
return `${folder}/${request.userId}/${timestamp}-${randomId}.${extension}`;
}
}
Advanced Features: Multipart Upload Support
For large files, S3 supports multipart uploads. Here's how to generate pre-signed URLs for each part:
async generateMultipartUploadUrls(
key: string,
partCount: number
): Promise<{ uploadId: string; urls: string[] }> {
// Initiate multipart upload
const createCommand = new CreateMultipartUploadCommand({
Bucket: this.bucketName,
Key: key,
});
const { UploadId } = await this.s3.send(createCommand);
// Generate presigned URLs for each part
const urls = await Promise.all(
Array.from({ length: partCount }, async (_, i) => {
const partCommand = new UploadPartCommand({
Bucket: this.bucketName,
Key: key,
PartNumber: i + 1,
UploadId,
});
return getSignedUrl(this.s3, partCommand, { expiresIn: 3600 });
})
);
return { uploadId: UploadId, urls };
}
Security Deep-Dive: Attack Vectors and Mitigations
Common Security Pitfalls
1. URL Leakage Pre-signed URLs in server logs, browser history, or referrer headers.
Mitigation:
// Use short expiry times
const shortLivedUrl = await getSignedUrl(s3, command, { expiresIn: 300 });
// Log only the key, never the full URL
logger.info(`Generated upload URL for key: ${key}`);
2. Replay Attacks Someone intercepts and reuses a pre-signed URL.
Mitigation:
// Add nonce to prevent replay
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
Metadata: {
nonce: crypto.randomUUID(),
"client-ip": request.ip,
},
});
3. Path Traversal Malicious users trying to access unauthorized paths.
Mitigation:
private sanitizeKey(userInput: string, userId: string): string {
// Remove any path traversal attempts
const sanitized = userInput.replace(/\.\./g, '').replace(/\//g, '_');
// Always scope to user's directory
return `users/${userId}/${sanitized}`;
}
Advanced Security: Custom Authorization
You can add additional validation layers:
async validateUploadPermission(userId: string, key: string): Promise<boolean> {
// Check user quota
const userUsage = await this.getUserStorageUsage(userId);
if (userUsage > this.getUserQuota(userId)) {
return false;
}
// Check file type restrictions based on user tier
const userTier = await this.getUserTier(userId);
const allowedTypes = this.getAllowedTypesForTier(userTier);
return allowedTypes.includes(this.getContentTypeFromKey(key));
}
Performance Optimization Strategies
1. URL Generation Caching
@Injectable()
export class OptimizedFileService {
private urlCache = new Map<string, { url: string; expiresAt: Date }>();
async getCachedUploadUrl(key: string): Promise<string> {
const cached = this.urlCache.get(key);
if (cached && cached.expiresAt > new Date()) {
return cached.url;
}
// Generate new URL with buffer time
const url = await this.generateUploadUrl(key);
const expiresAt = new Date(Date.now() + 240000); // 4 minutes (5 min URL - 1 min buffer)
this.urlCache.set(key, { url, expiresAt });
return url;
}
}
2. Batch URL Generation
async generateBatchUploadUrls(requests: UploadUrlRequest[]): Promise<UploadUrlResponse[]> {
// Process in parallel for better performance
return Promise.all(
requests.map(request => this.generateUploadUrl(request))
);
}
3. Regional Optimization
private getOptimalRegion(userLocation: string): string {
const regionMap = {
'US': 'us-east-1',
'EU': 'eu-west-1',
'APAC': 'ap-southeast-1',
};
return regionMap[userLocation] || 'us-east-1';
}
Monitoring and Observability
Key Metrics to Track
1. URL Generation Performance
@Injectable()
export class MetricsService {
async trackUrlGeneration(startTime: number, success: boolean, region: string) {
const duration = Date.now() - startTime;
// Custom metrics
this.prometheus.histogram("presigned_url_generation_duration_ms", duration);
this.prometheus.counter("presigned_url_generation_total", { success, region });
}
}
2. Upload Success Rates
// CloudWatch custom metric
await this.cloudWatch.putMetricData({
Namespace: "FileService",
MetricData: [
{
MetricName: "UploadSuccessRate",
Value: successRate,
Unit: "Percent",
Dimensions: [{ Name: "Environment", Value: "production" }],
},
],
});
Error Handling and Alerting
@Catch(S3ServiceException)
export class S3ExceptionFilter implements ExceptionFilter {
catch(exception: S3ServiceException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
// Log for monitoring
this.logger.error("S3 operation failed", {
error: exception.name,
code: exception.$metadata?.httpStatusCode,
requestId: exception.$metadata?.requestId,
});
// Return user-friendly error
response.status(500).json({
message: "File operation failed",
timestamp: new Date().toISOString(),
});
}
}
Comparison: Pre-Signed URLs vs Alternatives
Alternative 1: Server-Side Proxy
// Traditional approach - DON'T do this for large files
@Post('upload')
async uploadFile(@UploadedFile() file: Express.Multer.File) {
// File goes through your server - bandwidth 2x, latency high
return this.s3.upload({
Bucket: 'bucket',
Key: 'key',
Body: file.buffer,
}).promise();
}
Problems:
- Server bandwidth usage
- Memory usage for large files
- Single point of failure
- Increased latency
Alternative 2: Client-Side AWS SDK
// Client-side - DON'T do this
const s3 = new AWS.S3({
accessKeyId: "YOUR_KEY", // ❌ NEVER expose credentials
secretAccessKey: "YOUR_SECRET", // ❌ Security nightmare
});
Problems:
- Credentials exposure
- No fine-grained control
- Client-side secrets
Alternative 3: STS Temporary Credentials
// More complex but more flexible
const stsClient = new STSClient({ region: "us-east-1" });
const assumeRoleCommand = new AssumeRoleCommand({
RoleArn: "arn:aws:iam::account:role/UploadRole",
RoleSessionName: `upload-session-${userId}`,
});
const credentials = await stsClient.send(assumeRoleCommand);
// Return temporary credentials to client
Comparison Table:
Approach | Complexity | Security | Performance | Cost |
---|---|---|---|---|
Pre-signed URLs | Low | High | Excellent | Low |
Server Proxy | Medium | High | Poor | High |
Client SDK | Low | Very Poor | Good | Low |
STS Credentials | High | High | Good | Medium |
Real-World Challenges and Solutions
Challenge 1: Large File Uploads
Problem: Users uploading 1GB+ video files with unreliable connections.
Solution: Implement resumable uploads with multipart strategy:
class ResumableUploadService {
async initiateResumableUpload(fileSize: number, fileName: string) {
const chunkSize = 10 * 1024 * 1024; // 10MB chunks
const partCount = Math.ceil(fileSize / chunkSize);
const { uploadId, urls } = await this.generateMultipartUploadUrls(fileName, partCount);
return {
uploadId,
chunkSize,
totalParts: partCount,
uploadUrls: urls,
};
}
async completeResumableUpload(uploadId: string, parts: CompletedPart[]) {
const completeCommand = new CompleteMultipartUploadCommand({
Bucket: this.bucketName,
Key: this.key,
UploadId: uploadId,
MultipartUpload: { Parts: parts },
});
return this.s3.send(completeCommand);
}
}
Challenge 2: Rate Limiting
Problem: Users generating too many pre-signed URLs.
Solution: Implement intelligent rate limiting:
@Injectable()
export class RateLimitedFileService {
private rateLimiter = new Map<string, { count: number; resetTime: number }>();
async checkRateLimit(userId: string): Promise<boolean> {
const now = Date.now();
const window = 60000; // 1 minute
const limit = 100; // 100 URLs per minute
const userLimit = this.rateLimiter.get(userId);
if (!userLimit || now > userLimit.resetTime) {
this.rateLimiter.set(userId, { count: 1, resetTime: now + window });
return true;
}
if (userLimit.count >= limit) {
return false;
}
userLimit.count++;
return true;
}
}
Challenge 3: Cost Optimization
Problem: S3 requests and data transfer costs adding up.
Solution: Implement intelligent caching and regional optimization:
class CostOptimizedFileService {
async getOptimizedUploadUrl(request: UploadUrlRequest) {
// Use Intelligent Tiering for automatic cost optimization
const command = new PutObjectCommand({
Bucket: this.bucketName,
Key: this.generateKey(request),
StorageClass: "INTELLIGENT_TIERING",
// Add lifecycle rules via S3 console or CloudFormation
});
return getSignedUrl(this.s3, command, { expiresIn: 300 });
}
// Use CloudFront for download URLs to reduce S3 costs
async getOptimizedDownloadUrl(key: string) {
const cloudFrontUrl = `https://d1234567890.cloudfront.net/${key}`;
// Generate signed CloudFront URL instead of S3
return this.generateCloudFrontSignedUrl(cloudFrontUrl);
}
}
The Future of Object Storage Authorization
Emerging Patterns
1. Zero-Trust Architecture Every request is verified, even from internal services.
2. Policy-as-Code Fine-grained permissions defined in version-controlled policies.
3. Real-time Revocation Ability to instantly revoke access to pre-signed URLs.
AWS Innovations on the Horizon
S3 Object Lambda: Transform objects on-the-fly during retrieval.
// Future: Dynamic object transformation
const transformedUrl = await s3.getSignedUrl("getObject", {
Bucket: "my-bucket",
Key: "image.jpg",
ResponseContentType: "image/webp", // Automatic conversion
ResponseCacheControl: "max-age=3600",
});
S3 Access Grants: More granular access control coming soon.
What This Means for Developers
The trend is toward more granular, policy-driven access control while maintaining the simplicity that makes pre-signed URLs so powerful.
Key Takeaways
Pre-signed URLs represent a masterclass in distributed systems design:
1. Stateless Authorization: No server-side state required for validation.
2. Cryptographic Trust: Security through mathematics rather than databases.
3. HTTP Native: Works with existing web infrastructure seamlessly.
4. Horizontal Scalability: No bottlenecks, scales to billions of requests.
5. Developer Ergonomics: Simple to implement, hard to misuse.
The elegance lies not in complexity, but in how a simple cryptographic signature can replace an entire authorization infrastructure.
When you generate a pre-signed URL, you're not just creating a temporary link—you're participating in one of the most successful distributed authorization systems ever built.
Next time you use a pre-signed URL, remember: you're holding a cryptographically signed contract that AWS has never seen, will never store, but will always trust. That's the beauty of well-designed distributed systems.