StorageJuly 31, 202610 min read

S3 Presigned URLs: Direct Browser Uploads Without Handing Out Your Keys

A presigned URL isn't a token the storage issued you. It's a signature you computed yourself, and almost everything people get wrong about expiry, revocation, and safety follows from that. Here's the mechanism, the threat model, and how to pick an upload architecture.

RThe Runsite Team

File uploads start out simple. The browser posts a form to your app, your app reads the bytes, your app writes them to a bucket. For a profile picture that arrangement will keep working forever. Then someone uploads a 400 MB video and the shape of the problem changes: the request pins a worker for two minutes, the bytes sit in memory or a temp file, your platform's request timeout starts to matter, and every byte crosses the network twice on its way to a place it could have gone directly.

The fix is to take your application out of the byte path and let the browser write to storage itself. That raises the question that stops most people: the bucket is private, and the only credentials that can write to it live in your server's environment. A presigned URL is the standard way out, and it's worth understanding literally rather than by analogy, because most of the confusion around expiry, revocation, and safety traces back to picturing it as a token the storage handed you. What it actually is: an HMAC your own code produced, using a secret it already had, over a request you described in advance. Everything else in this article follows from that one sentence. (If buckets, keys, and prefixes are new territory, how S3-compatible object storage works sets up the model this article builds on.)

What is a presigned URL?

A presigned URL is an ordinary request to your storage endpoint with the authentication moved out of the headers and into the query string. Normally an S3 client signs a request by computing an HMAC over a canonical description of it (method, path, selected headers, timestamp) using your secret key, then putting the result in an `Authorization` header. Presigning runs the same computation and writes the output into the URL's query string instead:

  • `X-Amz-Algorithm` — which signing scheme was used, in practice `AWS4-HMAC-SHA256`.
  • `X-Amz-Credential` — the access key ID plus the date, region, and service it's scoped to.
  • `X-Amz-Date` — when the signature was created.
  • `X-Amz-Expires` — how many seconds it stays valid, counted from that date.
  • `X-Amz-SignedHeaders` — which headers the signature covers, so they can't be swapped in flight.
  • `X-Amz-Signature` — the HMAC itself.

When the request arrives, the storage looks up the secret belonging to that access key ID, recomputes the signature over the same elements, and compares. A match inside the validity window is an authorized request; anything else is a 403.

Two consequences fall out of that, and both catch people off guard. Generating a presigned URL is pure local computation: your code runs an HMAC with a secret it already has, so there's no API call, no round trip, and no request to pay for. You could mint ten thousand of them in a loop while offline. The other is the mirror image of the first. The storage has no record of the URLs you issued, because it never saw them issued. There is no list to inspect and no entry to delete.

python
# No network call happens here. It's an HMAC over the request you described.
url = s3.generate_presigned_url(
    "put_object",
    Params={
        "Bucket": "uploads",
        "Key": "user/42/9f3c1a.jpg",   # you generate this, not the client
        "ContentType": "image/jpeg",
    },
    ExpiresIn=300,
)

What the signature can and can't do

The URL inherits the signing key's permissions, and nothing beyond them

A presigned URL doesn't create a permission. It delegates one the signing credentials already had. If the access key that signed it can only write to one bucket, that's the ceiling for every URL it produces, no matter how the URL is described to the person receiving it. So "is it safe to hand this out" is really the question "what could the key that signed it do," which has a cheap answer: sign uploads with a write-scoped key rather than an admin one, and the worst case for a leaked URL stays inside a boundary you drew on purpose.

The clock is part of the signature

`X-Amz-Expires` is signed material, so nobody can extend a URL by editing it; changing the value invalidates the signature. It's also the only lifetime control the mechanism offers. On AWS at the time of writing, a URL signed with long-lived credentials tops out at seven days (604,800 seconds). Sign with temporary credentials instead, such as an assumed role, and the URL dies when that session does, even if the expiry you asked for was longer. Compatible providers may cap it lower or behave differently at the edges, which puts it on the list of things worth testing against a real bucket rather than assuming.

There is no revoke button for a single URL

Since the storage keeps no record of what you minted, there's nothing to delete when you want one back. The lever that does exist is the credential underneath: rotate or remove the access key, and every URL ever signed with it stops verifying at once. That's a sledgehammer, and it's the strongest practical argument for short expiries. A URL that lives five minutes needs no revocation story, because it revokes itself before anyone finishes writing the incident ticket.

Three ways to get a file into a bucket

With the mechanism clear, the design choice comes into focus. There are three arrangements in common use, and they differ mostly in how much you can force on the client.

What you're comparingProxy through your appPresigned PUTPresigned POST with policy
Where the bytes travelBrowser → your app → bucketBrowser → bucketBrowser → bucket
Who holds credentialsYour server, exclusivelyYour server signs, the browser gets a URLYour server signs, the browser gets fields and a policy
What you can force on the clientEverything: you're holding the fileKey, method, expiry, and any header you signedAll of that, plus a size range, a key prefix, and a content-type prefix
Practical size ceilingYour request timeout and memorySingle-PUT limit, 5 GB on AWS as of writingSame, with a range the storage enforces
What it costs youWorker time, memory, bandwidth on both legsOne HMACOne HMAC
Reach for it whenFiles are small and you must inspect them, or the uploader is a server-side job like a scheduled database backupThe client is trusted, the key is yours, and size is checked afterwardsBrowsers on the public internet are uploading arbitrary files
The same upload, three architectures. The column that usually decides it is what you can constrain, not where the bytes travel.

The proxy row is worth pricing before you dismiss it. Every byte crosses your application on the way in, occupying a worker and a chunk of memory for the whole transfer, and it crosses again on the way out whenever your app serves the file back instead of pointing at storage. On a metered provider that return leg is billable transfer, which is where S3 egress fees come from. Proxying is still the right answer when you genuinely need to look at the bytes before they land, such as scanning or transcoding on receipt. It's the wrong answer as a default.

The difference between the two presigned forms is the part tutorials tend to skip, and it's the whole decision. A presigned PUT signs one specific request: this method, this key, these headers, until this moment. What it doesn't do is constrain the body. A client handed a PUT URL for a 200 KB avatar can send three gigabytes instead, and it will be accepted, because size was never part of what you signed. Past the single-PUT ceiling the arrangement changes again, since the file has to travel in parts; uploading files to S3 in practice covers the multipart flow and what the browser ends up orchestrating.

Presigned POST is a different operation with a different shape. Rather than signing a URL you sign a short policy document describing what an acceptable upload looks like, then hand the browser that policy, its signature, and a set of form fields. The storage checks the upload against the policy before accepting it:

json
{
  "expiration": "2026-07-31T12:05:00Z",
  "conditions": [
    {"bucket": "uploads"},
    ["starts-with", "$key", "user/42/"],
    ["starts-with", "$Content-Type", "image/"],
    ["content-length-range", 1024, 5242880]
  ]
}

`content-length-range` is the line that earns the extra complexity. It turns a size limit into something the storage rejects at the door, rather than something you discover afterwards from a bill or a `HeadObject`. The prefix conditions do the same for key placement. Note that POST-with-policy is one of the S3 operations whose support varies across compatible providers, so confirm it works the way you expect on your provider before you design around it.

Are presigned URLs secure?

In the narrow sense the question usually means, yes: the mechanism exists precisely so a browser can touch storage without ever seeing your credentials, and the signature can't be modified without breaking. The failures happen one level up, in what the signature was never asked to cover.

A presigned URL is a bearer credential

Whoever holds the string can use it, exactly as the signature permits, until it expires. There's no identity check layered on top and no second factor. Treat one the way you'd treat a password reset link: fine to send to the person who asked for it, unwise to leave lying around in a log.

That framing turns most of the risk list into something concrete. Six patterns account for nearly every presigned-URL incident worth having an opinion about:

What bitesWhy it happensWhat to do instead
An expiry measured in daysThe window in which a leaked link still works is exactly the expiry you chose, and "a week, to be safe" is a common default.Minutes for uploads, minutes to an hour for downloads. Regenerating on demand costs an HMAC, so there's no reason to be generous.
The client chooses the object keyA key that arrives from the browser can point anywhere in the bucket, including over another user's object or outside your prefix.Generate the key server-side from the authenticated session, with a random component so it isn't guessable.
Trusting the declared content typeThe client states `Content-Type`, the storage records it and hands it back on download. It's a label the uploader wrote, not a verification.Check the bytes yourself after upload, and serve user content from a separate domain with an explicit `Content-Disposition`.
No ceiling on upload sizeA plain presigned PUT accepts whatever the client sends, up to the protocol limit.Use POST with `content-length-range` where it's supported. Otherwise `HeadObject` after the upload and delete anything oversized.
The URL ends up in logsQuery strings land in access logs, error-tracker breadcrumbs, analytics, and support screenshots, and the signature travels with them. Browsers help a little (the default `strict-origin-when-cross-origin` policy keeps the query string out of the `Referer` header on cross-origin navigation), but nothing protects you from your own logging.Redact query parameters in logging middleware and in your error tracker. Short expiries cap the exposure that gets through anyway.
A presigned GET behind a shared cacheA cache keyed on path rather than the full URL can hand one user's private object to the next request that asks for it.Send `Cache-Control: no-store` on private objects, or keep them out of shared caches. Assets meant for everyone belong in a public bucket behind a CDN.
What the signature doesn't cover, and what to do about each. All six are cheap to fix at design time and awkward to fix later.

One thing that belongs above the table rather than in it: your application still has to decide whether this user may upload here, before it signs anything. The signature proves the request carries your authority. It says nothing about who asked for it, and no amount of tightening the expiry compensates for an endpoint that signs a URL for anyone who calls it.

The upload your server never saw

Moving your application out of the byte path removes a guarantee you were relying on without noticing. Previously your app knew the upload had happened, because it did the uploading. Now the browser talks to storage and your server finds out only if you arrange for it to.

The version that looks obvious is to insert the database row at the moment you sign the URL. That row is a claim about a file that may never arrive: the user closes the tab, the connection drops on a train, the upload fails at 80%. Your listing page now renders entries whose objects don't exist, and every one of them is a 404 in front of a real user. The row has to lag the bytes, not lead them.

  1. The client asks to upload. You authorize the request, generate the object key yourself, and write a row in a `pending` state. The row is a reservation, not a fact.
  2. You sign a short-lived URL for that exact key and return it.
  3. The browser uploads straight to storage, then calls your API back to report that it finished.
  4. On that callback you run `HeadObject` against the key. It returns size, content type, `ETag`, and last-modified without transferring the bytes, so you can compare what landed against what you signed for.
  5. If the metadata checks out, the row moves to `ready`. That transition is the moment the file exists as far as your application is concerned.

The `HeadObject` is the load-bearing step. It's what converts a claim made by the client into something your server verified, at the cost of one request against a key you already know. Skip it and the callback becomes an unauthenticated "trust me, it worked" from the least trustworthy participant in the flow.

Two loose ends survive the callback, and a periodic sweep handles both. Rows still `pending` well past any plausible upload time were abandoned, so delete them along with anything sitting under their key. Objects with no row pointing at them are orphans, usually uploads that completed after you gave up waiting, and they quietly accumulate storage until something removes them. Where a provider supports event notifications, the storage can tell your application about a completed upload directly and the client's callback stops being the only signal, though that support varies between S3-compatible stores enough to belong in the compatibility checks you run against a live bucket.

Why it works in curl and fails in the browser

A presigned URL that uploads happily from a terminal and dies in the page is almost always CORS. `PUT` is not a simple method, so before the real request the browser sends an `OPTIONS` preflight. The presigned query string is still sitting on that request line, but the storage never checks a signature on an `OPTIONS`: it answers from the bucket's CORS configuration alone, as if your signature weren't there. Which means the bucket has to allow your origin, the methods you use, and the headers your client sets. If your code reads `ETag` off the response, that header also has to be listed as exposed, or the browser hides it from JavaScript even though it arrived. The console reports a generic CORS failure with no useful detail, which is why this one costs an afternoon the first time and five minutes forever after.

How Runsite handles it

On Runsite's S3-compatible object storage, presigned URLs cover both jobs from this article: sharing one private file, and letting a browser upload straight into a bucket. The API is the standard one, so `generate_presigned_url` in boto3, `getSignedUrl` in the JavaScript SDK, and `aws s3 presign` all work once the endpoint and keys point at Runsite, with nothing else in your code touched.

The revocation lever from earlier is the access key, and keys here are built for that use: each one carries a read, write, delete, or admin scope and rotates without downtime. Sign uploads with a write-scoped key and rotating it invalidates every outstanding URL it produced without disturbing the keys your other services use. CORS is configurable per bucket, buckets are private or public, and public ones are served through a global CDN with automatic cache headers, which keeps the line between "signed for one person" and "cached for everyone" where it belongs.

Transfer isn't metered in either direction, so receiving a direct browser upload costs nothing and serving a presigned download costs nothing. Gigabytes stored is the only variable left on the invoice, at €0.025 per GB per month after 5 GB free as of writing. Objects go up to 5 TB with multipart recommended above 100 MB, everything is encrypted at rest with AES-256 and in transit with TLS, and objects sit in the Frankfurt, Germany region under a signed GDPR Data Processing Agreement on every plan.

The short version

A presigned URL is a request you signed with your own key, with the signature moved into the query string. Minting one is free and offline; it can never grant more than the key behind it; and taking one back means rotating that key, which is why short expiries do most of the security work. Choose the form by how much you need to constrain the uploader: a presigned PUT pins the key, the method, and the deadline while leaving the body wide open, and a presigned POST adds a policy the storage enforces on size, prefix, and content type. Either way the signature only proves the request carries your authority, so authorize the user before you sign, generate keys server-side, keep the URL out of your logs, and confirm the object with a `HeadObject` before your application treats it as real. To try the whole flow end to end, 5 GB is free, which is enough to sign a URL, upload straight from a browser, and watch the key appear.

FAQ

Frequently Asked Questions

Common questions about this service.

A presigned URL is a normal request to your object storage with the authentication moved from the headers into the query string. Your application computes an AWS Signature Version 4 HMAC over the request using its own secret key, and the result travels in parameters like X-Amz-Credential, X-Amz-Date, X-Amz-Expires, X-Amz-SignedHeaders, and X-Amz-Signature. When someone uses the URL, the storage recomputes the signature with the same secret and allows the request if it matches and hasn't expired. Because the signature is computed locally, generating a presigned URL involves no API call and costs nothing, and the storage keeps no record that the URL exists.

They're secure in the sense that they never expose your credentials and can't be modified without invalidating the signature. What they are is a bearer credential: anyone holding the string can use it, exactly as signed, until it expires. The practical risks live around the edges rather than in the cryptography. Keep expiries in minutes rather than days, generate the object key on the server so a client can't overwrite someone else's file, treat any client-declared content type as unverified, cap upload size with a presigned POST policy where it's supported, redact query strings in your logs and error tracker, and keep presigned downloads out of shared caches. Also authorize the user before signing, since the signature proves your application approved the request but says nothing about who asked.

The lifetime is set by X-Amz-Expires when you sign, and it's part of the signed material, so it can't be extended afterwards. On AWS at the time of writing, a URL signed with long-lived credentials can last at most seven days (604,800 seconds); if you sign with temporary credentials, it stops working when that session expires regardless of the value you asked for, and compatible providers may impose their own limits. There's no way to revoke a single URL, because the storage never recorded it being issued. The only lever is the credential that signed it: rotating or deleting that access key invalidates every URL produced with it at once. That bluntness is the main reason to keep expiries short.

A presigned PUT signs one specific request, fixing the object key, the HTTP method, any headers you included, and the expiry. It places no limit on the request body, so a client given a PUT URL for a small image can upload a multi-gigabyte file instead. A presigned POST signs a policy document rather than a URL, and the storage validates the upload against that policy before accepting it. The policy can require conditions such as content-length-range for a minimum and maximum size, and starts-with rules on the key prefix and content type. Use PUT when the uploader is trusted and you generate the key yourself; use POST when a browser on the public internet is uploading and you want the storage to enforce the limits. POST policy support varies between S3-compatible providers, so verify it on a live bucket first.

Your app deserves to be online

Free to start. Deploy in under a minute. No credit card needed.