Generating Secure Presigned URLs with AWS SDK v3 for Direct-to-Cloud Uploads
Modern file upload architectures bypass backend bottlenecks by routing payloads directly to cloud storage. This guide details how to generate secure, time-bound presigned URLs using the modular AWS SDK v3. Engineers will learn to configure IAM boundaries, enforce content-type locking, and implement robust S3 Presigned URL Workflows for production-grade media processing pipelines.
Direct uploads eliminate server memory overhead and reduce latency. However, they require strict adherence to Backend Validation & Cloud Storage Architecture principles. You must validate metadata before issuance and enforce cryptographic constraints during transit.
Key implementation targets:
- Initialize
@aws-sdk/client-s3with modular imports - Configure strict IAM policies for scoped
PutObjectaccess - Implement server-side validation before URL issuance
- Handle CORS, clock skew, and expiration edge cases
1. IAM Policy & Bucket Configuration
Establish least-privilege access controls and CORS rules required for secure direct uploads. Never grant broad s3:* permissions to application roles.
Define a scoped IAM policy that restricts actions to s3:PutObject and s3:PutObjectAcl. Scope the resource ARN to a specific bucket and optional prefix.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:PutObjectAcl"
],
"Resource": "arn:aws:s3:::your-secure-bucket/uploads/*"
}
]
}
Configure S3 CORS to explicitly allow PUT requests from trusted frontend origins. Omitting Access-Control-Request-Headers will trigger preflight failures.
<CORSConfiguration>
<CORSRule>
<AllowedOrigin>https://app.yourdomain.com</AllowedOrigin>
<AllowedMethod>PUT</AllowedMethod>
<AllowedHeader>Content-Type</AllowedHeader>
<AllowedHeader>x-amz-meta-user-id</AllowedHeader>
<ExposeHeader>ETag</ExposeHeader>
<MaxAgeSeconds>3600</MaxAgeSeconds>
</CORSRule>
</CORSConfiguration>
Enable S3 Block Public Access by default. Attach lifecycle rules to automatically abort incomplete multipart uploads after 7 days. This prevents storage bloat from interrupted client sessions.
2. Backend Presigned URL Generation (Node.js)
Implement the core SDK v3 logic to issue cryptographically signed URLs with strict constraints. Use @aws-sdk/s3-request-presigner for type-safe, modular generation.
Lock Content-Type and custom headers at generation time. Any deviation during upload will invalidate the signature. Set explicit expiration to limit the exposure window.
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { v4 as uuidv4 } from "uuid";
const s3Client = new S3Client({
region: process.env.AWS_REGION,
maxAttempts: 3,
requestTimeout: 5000,
});
export async function generateUploadUrl(
userId: string,
fileType: string,
fileSizeBytes: number
): Promise<string> {
const allowedTypes = ["image/png", "image/jpeg", "application/pdf"];
if (!allowedTypes.includes(fileType)) {
throw new Error("Unsupported MIME type");
}
if (fileSizeBytes > 50 * 1024 * 1024) {
throw new Error("File exceeds 50MB limit");
}
const key = `uploads/${userId}/${uuidv4()}`;
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET_NAME,
Key: key,
ContentType: fileType,
ContentLength: fileSizeBytes,
Metadata: {
"user-id": userId,
"upload-initiated": new Date().toISOString(),
},
});
const signedUrl = await getSignedUrl(s3Client, command, {
expiresIn: 900,
signableHeaders: new Set(["content-type", "x-amz-meta-user-id"]),
});
return signedUrl;
}
Wrap the generation logic in a transactional database record. Track pending uploads and reconcile them against successful S3 events.
3. Frontend Direct Upload Execution
Consume the presigned URL securely from the client without exposing AWS credentials. Fetch the signed URL via an authenticated backend endpoint first.
Execute the PUT request with exact header matching. The browser’s fetch API must mirror the signed constraints precisely.
async function uploadToS3(file, signedUrl, metadata) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
try {
const response = await fetch(signedUrl, {
method: "PUT",
headers: {
"Content-Type": file.type,
"x-amz-meta-user-id": metadata.userId,
},
body: file,
signal: controller.signal,
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`S3 Upload Failed: ${response.status} - ${errorBody}`);
}
return response.headers.get("ETag");
} catch (error) {
if (error.name === "AbortError") {
throw new Error("Upload timed out after 30s");
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
Handle 403 SignatureDoesNotMatch by verifying frontend headers against the backend generation payload. Implement exponential backoff for transient network 5xx responses.
4. Security Hardening & Post-Upload Validation
Close security gaps and trigger downstream processing after successful upload. Direct uploads bypass traditional middleware, requiring asynchronous validation.
Configure S3 EventBridge notifications to route s3:ObjectCreated:Put events to a validation queue. Trigger server-side virus scanning and MIME verification before moving objects to a public or processed prefix.
Validate the uploaded object ETag against the client-side hash. Mismatched checksums indicate corruption or tampering during transit.
import { HeadObjectCommand, CopyObjectCommand } from "@aws-sdk/client-s3";
export async function verifyUpload(bucket: string, key: string, expectedEtag: string) {
const headCommand = new HeadObjectCommand({ Bucket: bucket, Key: key });
const metadata = await s3Client.send(headCommand);
const actualEtag = metadata.ETag?.replace(/"/g, "");
if (actualEtag !== expectedEtag) {
await s3Client.send(new CopyObjectCommand({
Bucket: bucket,
Key: `quarantine/${key}`,
CopySource: `${bucket}/${key}`,
MetadataDirective: "REPLACE",
Metadata: { "status": "quarantined", "reason": "etag_mismatch" }
}));
throw new Error("ETag mismatch detected. Object quarantined.");
}
}
Enforce metadata indexing for search and compliance auditing. Rotate signing keys periodically and monitor CloudTrail for anomalous PutObject patterns outside expected IP ranges.
Diagnostic Steps for Common Failures
SignatureDoesNotMatch (403)
Frontend headers differ from the exact values signed in the URL. Run a diff between the fetch headers object and the backend signableHeaders array. Ensure Content-Type casing matches exactly.
CORS Preflight Failure
S3 blocks the OPTIONS request before the upload begins. Verify the bucket CORS XML explicitly lists PUT under <AllowedMethod>. Check browser dev tools for Access-Control-Allow-Origin mismatches.
Clock Skew Errors
Server or client time drifts beyond AWS tolerance. Run ntpq -p on your application servers. If drift persists, initialize S3Client with systemClockOffset: Date.now() - awsNtpTime to compensate.
Frequently Asked Questions
Why use AWS SDK v3 instead of v2 for presigned URLs?
SDK v3 uses a modular, tree-shakable architecture that reduces bundle size. It supports modern async/await natively and provides the dedicated @aws-sdk/s3-request-presigner package for cleaner, type-safe URL generation.
How do I prevent users from uploading malicious files via presigned URLs?
Presigned URLs only grant transport access. Implement server-side validation triggers (e.g., EventBridge -> Lambda -> ClamAV) post-upload. Enforce strict MIME type locking and quarantine objects until scanning completes.
What is the maximum expiration time for a presigned URL?
AWS allows up to 7 days (604800 seconds). For production security, limit expiration to 15-30 minutes. Implement short-lived token refresh mechanisms for large file workflows.