Presigned URL uploads to object storage: security boundaries, pipelines, and async verification
Ship browser-to-bucket uploads without proxying bytes through your API. Policy fields, content-type constraints, key layout, malware scanning hooks, and the failure modes teams hit in production.
A product manager asks for direct-to-cloud uploads: large design files, raw telemetry dumps, or user-generated media. Your first instinct—a multipart handler on the API that streams to disk or memory—works until traffic spikes and your Node.js event loop, ingress bandwidth, and horizontal scaling story all argue for a different shape. Presigned URLs (or presigned POST policies) push the heavy lifting to object storage while your service stays a small, auditable orchestrator of permissions and metadata.
This article walks through why presigned flows reduce blast radius, how to constrain them so attackers cannot turn your bucket into a free CDN, and what to do after the object lands (virus scanning, transcoding, access control). The patterns come up repeatedly in freelance API work and in teams modernizing legacy “upload to the app server” designs: the goal is least privilege, predictable cost, and a clear line between who may write and who may read.
The architecture in one sentence
Your API authenticates the user, authorizes an upload (size, type, tenant), mints a short-lived credential scoped to one key (or POST policy), returns it to the client, and the client PUTs or POSTs bytes straight to storage. Your database records intent before or after the upload, depending on how you want to handle abandoned objects.
That separation matters: the object store credential never embeds your session cookie, and your API never sees the raw file bytes unless you choose to.
Presigned PUT vs presigned POST: trade-offs
Presigned PUT
You sign a PutObject-style request for a fixed object key, optional headers (Content-Type, Content-MD5, SSE parameters), and an expiry (typically minutes). The client issues PUT with the exact headers the signature covers.
Pros
- Simple mental model: one URL, one object.
- Easy to integrate with mobile and web clients that already speak
PUT.
Cons
- Header mismatch yields 403 with opaque errors for junior client code.
- You must decide the final key before the client uploads—usually fine if keys are deterministic (
tenantId/uuid.ext).
Presigned POST (policy document)
You return a URL plus form fields (key, policy, signature, x-amz-*). The client POSTs multipart/form-data with the file field.
Pros
- Policies can express max size (
content-length-range) and starts-with constraints on keys—useful when the client proposes a suffix under a prefix you control. - Some teams find browser
FormDataergonomics simpler than header-perfectPUTs.
Cons
- More moving parts in the client.
- You still need server-side validation of business rules (quota, entitlements); the policy is not a substitute for authorization in your domain.
For JSON APIs consumed by first-party web apps, presigned PUT is often enough. For browser-heavy uploads with looser client control, POST policies can reduce foot-guns around Content-Type.
Designing the object key and metadata
Treat the object key as data you will query and bill on:
- Prefix by tenant or account (
tenants/{tenantId}/uploads/{uploadId}) so lifecycle rules, bucket policies, and analytics stay partitionable. - Avoid guessable keys for semi-public content; use UUIDs or ULIDs.
- Store canonical metadata in your database:
s3Key,sha256(if computed client-side or post-scan),byteSize,contentType,createdBy,purpose(avatar vs attachment).
When helping teams harden multi-tenant products, the recurring bug is key injection: accepting a client-provided path without normalizing it into a server-owned prefix. Never concatenate raw filenames into the key without sanitization and a bounded allowlist.
Locking down Content-Type and size
If you only sign a URL and let the client pick any Content-Type, you invite MIME confusion attacks downstream (processors that sniff type, antivirus engines with format-specific parsers) and make CDN caching harder to reason about.
Better practice
- Decide allowed MIME types in your API from product rules.
- Include
Content-Typein the signature for PUT flows so the client cannot swap it after minting. - Enforce maximum size at the policy level (POST) or via pre-flight HEAD plus S3 bucket policies where applicable; also enforce quotas in your app DB.
For AWS S3, newer checksum headers (x-amz-checksum-sha256, etc.) let clients prove integrity; pairing that with async verification (compute hash in a worker and compare) catches truncated uploads and tampering after the fact.
The upload lifecycle: three durable patterns
1. Record-first (“pending”) then complete
- API creates a DB row
uploadsinpendingwith expected metadata. - Client uploads to storage using the presigned credential.
- Client calls
POST /uploads/:id/complete(or S3 event → Lambda) to markreadyand optionally enqueue processing.
Why: you always have a place to attach virus scan results, transcode jobs, and user-visible errors without orphaned objects flooding anonymous prefixes—though you still need a sweeper for pending rows that never complete.
2. Event-first (S3 notification)
S3 emits an event on s3:ObjectCreated:*; a worker validates, scans, and updates your DB.
Why: the client cannot forget to “finalize”; good for trusted first-party clients only if you still verify auth context via object metadata or a sidecar manifest.
Risk: ensure idempotency and ordering; duplicate notifications happen.
3. Two-step visibility with tag or prefix promotion
Write to staging/ with a lifecycle rule; after verification, copy to published/ or flip a tag that IAM policies respect for read paths.
Why: readers never see unverified bytes, at the cost of an extra copy operation and slightly higher complexity.
Teams building production-ready pipelines usually combine 1 and 2: the API owns intent and authorization; events drive heavy async work.
Practical example: presigned PUT in Node.js (AWS SDK v3)
The following sketch shows a minimal record-first flow: the API returns a URL the browser can PUT to, with a pinned content type and SSE if your org requires it.
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { randomUUID } from "node:crypto";
const s3 = new S3Client({ region: process.env.AWS_REGION });
type MintUploadParams = {
tenantId: string;
userId: string;
contentType: "image/png" | "image/jpeg";
maxBytes: number;
};
export async function mintAvatarUpload(params: MintUploadParams) {
const uploadId = randomUUID();
const key = `tenants/${params.tenantId}/avatars/${uploadId}`;
// 1) Persist intent (pseudo): INSERT uploads ...
const command = new PutObjectCommand({
Bucket: process.env.UPLOAD_BUCKET,
Key: key,
ContentType: params.contentType,
// Server-side encryption at rest (optional but common in regulated spaces)
ServerSideEncryption: "aws:kms",
SSEKMSKeyId: process.env.UPLOAD_KMS_KEY_ID,
Metadata: {
tenantid: params.tenantId,
userid: params.userId,
purpose: "avatar",
},
});
const url = await getSignedUrl(s3, command, { expiresIn: 120 });
return {
uploadId,
key,
method: "PUT" as const,
url,
headers: {
"Content-Type": params.contentType,
},
maxBytes: params.maxBytes,
};
}
Client-side (browser), the caller must attach the same Content-Type header used when signing:
async function uploadAvatar(file: File, minted: Awaited<ReturnType<typeof mintAvatarUpload>>) {
if (file.size > minted.maxBytes) throw new Error("File too large");
const res = await fetch(minted.url, {
method: "PUT",
headers: minted.headers,
body: file,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Upload failed: ${res.status} ${text}`);
}
await fetch(`/api/uploads/${minted.uploadId}/complete`, { method: "POST" });
}
In production you would tighten maxBytes against product limits, add auth to the mint route, and emit metrics on 403 rate (often a signature drift or header mismatch). After complete, a worker might generate thumbnails, strip EXIF if privacy requires, and update the user profile with the versioned key—never a mutable “latest.jpg” without invalidation strategy.
CORS, TLS, and bucket policies
CORS on the bucket must allow PUT from your web origins and expose headers your client reads (often none beyond status). Keep origins explicit; avoid * with credentials.
Bucket policy should deny s3:GetObject for staging prefixes from the public, allow PutObject only via roles used by presigning or constrain by prefix and TLS. Block public ACLs at the account level (Block Public Access).
Async verification: malware and content validation
Presigning solves transport and API load, not trust. Treat uploaded bytes as hostile:
- ClamAV or commercial scanners in async workers, with timeouts and format limits.
- Image parsers that decode in sandboxes for image pipelines; reject polyglots that look like PNG and ZIP.
- Magic-byte checks in addition to MIME allowlists—MIME is declarative, not proof.
When consulting on compliance-heavy verticals, the pattern is quarantine → scan → promote with explicit SLA for when objects become visible; skipping that step is how customer portals become malware distribution points.
Common mistakes and pitfalls
- Over-long expiries on presigned URLs logged in analytics or error reports. Treat URLs like passwords: short TTL, one-time use where possible, and never embed in emails unless necessary.
- Unsigned client-chosen keys that escape tenant boundaries or collide across users.
- Trusting
Content-Typefrom the browser without pinning it in the signature for PUT flows. - Missing lifecycle rules on staging prefixes → runaway storage cost from abandoned uploads.
- Reading objects before verification completes, especially in CDN-backed download paths where caching makes rollback painful.
- Assuming S3 events are exactly-once; workers must tolerate duplicates and out-of-order delivery.
Conclusion
Presigned uploads are a sharp tool: they shrink attack surface on your API, improve tail latency for large files, and align costs with storage rather than compute—if you treat signing as authorization code, not a convenience shortcut. Invest in key layout, header discipline, pending/completed state machines, and async verification the same way you would for any other untrusted input path.
The durable takeaway is architectural: separate who may initiate an upload from who may read bytes and what processing must run before promotion. Teams shipping scalable, production-ready systems tend to document that split in their runbooks and test it—including failure cases like slow clients, signature expiry, and duplicate completion calls. For more on how this site frames engineering work, see About; for collaboration or inquiries, Contact.
Subscribe to the newsletter
Get an email when new articles are published. No spam — only new posts from this blog.
Powered by Resend. You can unsubscribe from any email.