Riajul Islam
← Back to blog
Backend

Direct File Uploads With Signed URLs, Done Safely

The browser uploads straight to storage and your server never holds the bytes — plus the four checks that stop a signed URL being an open write endpoint.

The obvious way to handle file uploads is to post them to your API, which then forwards them to storage. It works, it is easy to reason about, and it stops working the first time somebody uploads a 400MB video.

The problem is not any single large file. It is that every byte crosses your server twice, a request stays open for the duration, and a platform with a request timeout — which is all of them — will cut a slow upload off partway through with no useful error.

The fix is to take your server out of the data path entirely. It signs a short-lived URL, the browser uploads directly to storage, and your API is told afterwards. Here is that flow with the security holes closed, because the naive version has several.

Why not just proxy the upload?#

Four reasons, and the last one is the one that decides it on serverless platforms.

Through your APIDirect to storage
Bandwidth paidTwiceOnce
Request durationLength of the uploadMilliseconds
Memory usedFile size, or streaming complexityNone
Works on a serverless platformUp to the body limitAny size
Proxying versus direct upload

That body limit is a hard wall rather than a slow degradation. Most serverless request handlers cap the request body at a few megabytes, and no amount of streaming cleverness gets around it because the limit is enforced before your code runs.

The direct pattern makes file size irrelevant to your application. A 2KB avatar and a 2GB export use the same code path, and the only thing that changes is how long the browser is busy.

What does the flow actually look like?#

Three steps, and the third is the one people skip.

  1. Request permission. The client tells your API what it wants to upload — filename, content type, size. The API decides whether that is allowed, generates a key, and returns a signed URL.
  2. Upload directly. The browser PUTs the file to that URL. Your server is not involved and does not know when it finishes.
  3. Confirm. The client calls your API again. The API verifies the object exists, checks its actual size and type, and only then creates the database record.
// Step 1 — the server decides the key. Never accept one from the client.
const key = `${orgId}/${crypto.randomUUID()}${extname(filename)}`;
const url = await storage.signedUrl(key, {
  method: 'PUT',
  expiresIn: 300,
  contentType,                 // baked into the signature
  contentLengthRange: [1, MAX_BYTES],
});
await db.upload.create({ data: { key, orgId, status: 'pending' } });

Creating a pending record at step one rather than at step three gives you something to reconcile against. Without it, an upload that completes but never gets confirmed is an orphaned object nobody knows about, and there is no way to distinguish it from a file somebody deleted.

The other thing step one is for is refusing the upload before it happens. A user on a plan with a 10MB limit trying to send 400MB, an account over its storage quota, a file type you do not accept — all of these should be a clear error from your API in a hundred milliseconds, not a rejection after the browser has spent four minutes transferring. The signing endpoint is where every policy decision belongs, because it is the last point at which refusing is cheap.

Why must the server generate the key?#

Because a client-supplied key is a client-supplied path, and a path is where the data ends up.

If the client sends filename and you sign a URL for it, a client sending ../../config/settings.json or another tenant's known key gets a signed URL to write there. Signing is not authorization — it authorizes exactly what you signed, which is the whole problem when what you signed came from the caller.

// Wrong: the caller chooses where its data lands.
const key = `uploads/${req.body.filename}`;

// Right: the server composes the key from facts it controls.
const key = `${orgId}/${crypto.randomUUID()}${sanitizedExt}`;

Keep the original filename as metadata, not as the path#

Users care that their download is called Q3 report.pdf. That belongs in a database column and in the Content-Disposition header at download time — not in the storage key, where it introduces encoding problems, collisions and the traversal risk above.

Random identifiers rather than sequential ones matter here too. A key containing an incrementing number tells anyone who obtains one URL that neighbouring files exist, and invites walking the range to find out what else is there.

Prefix every key with the tenant#

Object storage has no tenant column, so the prefix is the only structure available. Leading every key with the organization id means a storage-level policy can be written against it, and it makes an accidental cross-tenant read visible rather than plausible.

What must the signature constrain?#

Four things, and a signed URL missing any of them is looser than it looks.

ConstraintWithout it
ExpiryA permanent write capability for that key
MethodA URL meant for upload may also allow other operations
Content typeAn "image" upload accepts an HTML file that then serves from your domain
Content length rangeA user fills your bucket with a single request
What to bake into the signature

The content-type row is the one with teeth. If a user can upload HTML or SVG to a bucket served on your domain, they can serve script from your origin — which is a cross-site scripting hole delivered through a file upload, and it bypasses every content security policy scoped to self.

The size constraint is worth insisting on even though it is the one most often skipped, because it is the difference between a storage bill you can predict and one you cannot. Without a length range in the signature, a signed URL issued for a profile photo will happily accept a hundred gigabytes, and the only thing stopping that is the goodwill of whoever holds the URL.

Keep the expiry short#

Five minutes is generous for starting an upload. The window only needs to cover the time between your API responding and the browser beginning the transfer — not the transfer itself, which continues once started.

Serve user content from a separate domain#

Even with content types constrained, user-uploaded files should not be served from the domain that holds your session cookies. A separate domain means a file that does manage to execute has no access to anything that matters. This is cheap to set up on day one and awkward once URLs are in circulation.

Why does the confirm step matter?#

Because everything the client told you before the upload was a claim, and the upload is where claims become checkable facts.

A client can request a signed URL for a 2MB PNG and then upload nothing at all, or upload something else within the constraints, or upload correctly and then lie about it. Only the server checking the object after the fact resolves any of that.

// Step 3 — verify against storage, not against what the client says.
const head = await storage.head(key);
if (!head) return badRequest('upload_not_found');
if (head.size > MAX_BYTES) { await storage.delete(key); return badRequest('too_large'); }
if (!ALLOWED.has(head.contentType)) { await storage.delete(key); return badRequest('bad_type'); }

await db.upload.update({ where: { key }, data: { status: 'ready', size: head.size } });

Content type from the header is still a claim#

The stored content type is whatever was declared at upload. For anything that will be rendered or processed, check the actual bytes — the file signature in the first few bytes tells you what it really is, and a mismatch between that and the declared type is a strong signal that something deliberate is happening.

There is a second reason the confirm step earns its place, and it is not about security at all. It is the only moment where your application learns that a file is actually usable. Without it, every downstream feature has to cope with a record that might point at nothing, and that uncertainty spreads — the gallery, the export, the email attachment all need a defensive check. One confirm step removes it from all of them.

Reconcile the pending records#

A scheduled job that deletes pending uploads older than an hour — both the row and any object — keeps the orphans from accumulating. Without it, every abandoned upload is storage you pay for indefinitely and cannot identify later.

How do you handle large files?#

Multipart uploads, which are the same pattern with more signatures.

Above a few hundred megabytes a single PUT becomes fragile — a dropped connection at ninety percent means starting again. Multipart splits the file into parts, each uploaded independently and retryable on its own, then combined by storage at the end.

  1. Initiate on the server, which returns an upload id.
  2. Sign a URL per part. The client uploads parts in parallel and collects an identifier for each.
  3. Complete on the server with the ordered list of part identifiers.

The operational detail nobody expects: an initiated multipart upload that is never completed leaves its uploaded parts in storage, billed, and invisible in a normal object listing. A lifecycle rule that aborts incomplete uploads after a few days is the only thing that cleans them up, and it is one setting.

Do not build this until you need it#

Multipart triples the number of round trips and adds real state to manage. For a product where uploads are images and documents, a single PUT with a size cap is simpler and sufficient. Add multipart when file sizes actually demand it, not in anticipation.

What happens after the file lands?#

Usually something — a thumbnail, a text extraction, a conversion — and where you trigger it decides how the whole thing fails.

Trigger from your confirm step, not from a storage event#

Storage event notifications look like the elegant option and they decouple the processing from a call you control. The cost is that the event carries no application context: it knows a key appeared and nothing about which record, which tenant or whether the upload was ever confirmed. Triggering from your own confirm handler means the job starts with everything it needs.

Process in a job, not in the request#

Image resizing and document parsing are slow and occasionally fail. Doing them inline makes the confirm call slow and turns a processing failure into an upload failure, which is a confusing thing to show a user whose file uploaded perfectly. Enqueue, return, and let the record carry a processing status.

Keep the original, always#

Derived files can be regenerated; originals cannot. Storing only the resized version saves storage and removes the ability to change your mind about dimensions, formats or quality later — which you will, the first time the design changes.

Make the status visible in the API#

A record that is uploaded but not yet processed should say so, rather than appearing complete with a missing thumbnail. Three states — pending, ready, failed — is enough, and it lets the client show something honest instead of a broken image.

How do downloads work?#

The same way in reverse, and the access decision has to happen on every single one.

The tempting shortcut is to store the signed URL in the database when the file is uploaded. That turns a time-limited capability into a permanent one, and it means anyone who obtains the URL — from a shared screenshot, a forwarded email, a browser history — has the file forever.

// Generate at read time, after the permission check. Never store the URL.
const file = await db.upload.findFirst({ where: { id, orgId: session.orgId } });
if (!file) return notFound();
return { url: await storage.signedUrl(file.key, { method: 'GET', expiresIn: 60 }) };

Notice that the ownership check is part of the query rather than a comparison afterwards — the same rule as everywhere else a record is fetched, and for the same reason: a scoped query cannot return the wrong row, while a comparison can be skipped.

Expiry on downloads should be short for a reason people find counterintuitive: a download URL only needs to survive long enough for the browser to start fetching it. Once the transfer has begun it continues regardless of whether the signature has since expired, so sixty seconds is ample even for a large file, and it drastically narrows the window in which a leaked URL is worth anything.

Public files can just be public#

Not everything needs signing. Product images on a storefront, blog illustrations and marketing assets are public by intent, and putting them behind signed URLs costs a round trip per view and breaks CDN caching for no security benefit. Signing is for files with an access rule.

Signed URLs and caching do not mix#

Every signed URL is unique, so a CDN caches each one separately and the hit rate collapses. For private files served frequently — a document viewer, an image gallery behind auth — a longer expiry with a stable URL per session, or a proxy that authenticates and caches, is usually better than signing per request.

What breaks in practice?#

Five things, in roughly the order I encounter them on projects that already have uploads working.

  • CORS. Direct browser uploads need the bucket to allow your origin, and the deployed origin is invariably the one that was never added. It works locally and fails in production.
  • Clock skew. Signatures are time-based. A server whose clock has drifted issues URLs that are already expired or not yet valid, and the error message says neither.
  • Progress reporting. A direct upload gives the browser real progress events, which is a genuine improvement — but only if the client uses them. Otherwise a large upload looks frozen.
  • Retries duplicating objects. A retried upload with a freshly generated key leaves the first object behind. Reuse the key on retry so the second attempt overwrites rather than accumulates.
  • No virus scanning. If users upload files that other users download, something has to scan them. Trigger it on the confirm step and keep the file unavailable until it passes.

The last one is easy to dismiss on an internal tool and not dismissible at all on anything where one customer's upload reaches another customer. That is a distinction worth making explicitly rather than by default.

A signed URL is a capability, not a request. Everything you did not constrain in the signature is something the holder is allowed to do.

What does this cost to build?#

Half a day, and most of that is the parts that are not the upload.

The signing endpoint, the client PUT and the confirm handler are perhaps an hour between them on either R2 or S3, since the API is the same shape on both. What takes the rest is CORS configuration on the bucket, the cleanup job for pending records, the lifecycle rule for incomplete multipart uploads, and deciding where user content is served from.

The honest counterweight is that this is more moving parts than a proxied upload. Three round trips instead of one, a pending state to reconcile, and a failure mode — the confirm call never arriving — that does not exist when your server holds the file. On a project where uploads are small and rare, proxying is genuinely the simpler choice and I would not talk anybody out of it.

Conclusion#

Sign a short-lived URL on the server, let the browser upload directly to storage, and confirm afterwards. Your API never holds the bytes, so file size stops being an application concern and serverless body limits stop mattering.

Generate the key on the server from facts you control — tenant prefix, random identifier, sanitized extension — and never from anything the client sent. A signature authorizes exactly what you signed, so a caller-supplied path is a caller-chosen destination.

Constrain the signature on all four axes: expiry, method, content type and size range. The content type matters most, because an unconstrained upload of HTML or SVG to a bucket on your own domain is a scripting hole that bypasses a self-scoped content security policy.

Verify after the upload rather than trusting the confirmation. Head the object, check its real size and type, delete it if it fails, and reconcile pending records on a schedule so abandoned uploads do not accumulate as storage nobody can identify.

For downloads, generate the URL at read time after an ownership-scoped query, and never store a signed URL — that converts a time-limited capability into a permanent one. Serve user content from a separate domain, and skip signing entirely for files that are public by intent. If you are adding uploads to something and want the signing path reviewed before users can reach it, that is a quick job.

Frequently asked questions

Why upload directly to storage instead of through the API?

Bandwidth is paid once instead of twice, the request finishes in milliseconds instead of staying open for the transfer, and file size stops mattering. On serverless platforms the request body limit is a hard wall enforced before your code runs, so proxying caps uploads at a few megabytes.

Is a signed upload URL safe to give to the browser?

Only if the signature is constrained. Bake in a short expiry, the HTTP method, the content type and a maximum size, and generate the storage key server-side. An unconstrained signed URL is an open write endpoint for whoever holds it, for as long as it lasts.

Do you still need to check the file after a direct upload?

Yes. Everything the client said before the upload was a claim. Head the object to confirm it exists, check the real size and content type against your limits, delete it if it fails, and only then create the record. For rendered files, check the actual bytes rather than the declared type.

Should download URLs be signed and stored in the database?

Signed yes, stored no. A stored signed URL turns a time-limited capability into a permanent one that works for anyone who obtains it. Generate it at read time after an ownership-scoped query, with a short expiry. Files that are public by intent do not need signing at all.

Read next Idempotency Keys: Making Retries Safe on Your Own API

Got a project worth
writing about?