A presigned URL is an ordinary S3 request with the signature moved into the query string, so whoever holds the URL can make that one request - download that object, or upload to that key - until the URL expires, without ever seeing your keys. Your server generates it in a few microseconds without contacting storage at all, hands it to a browser or a script, and gets out of the way. That is the right pattern for user uploads: the file goes straight from the browser to storage instead of through your app, so a 2 GB video does not tie up a worker or fill the app server's disk. The parts that go wrong are always the same four: the hostname the URL was signed for, headers the browser sends that were not signed, CORS, and expiry. This post covers all of them, with working code in Python and Node.
How a presigned URL works#
A normal S3 request carries its signature in the Authorization header. A presigned request carries the same information as query parameters:
https://s3.example.com/media/uploads/3f9c2a.jpg ?X-Amz-Algorithm=AWS4-HMAC-SHA256 &X-Amz-Credential=AKIA...%2F20261008%2Fus-east-1%2Fs3%2Faws4_request &X-Amz-Date=20261008T101500Z &X-Amz-Expires=900 &X-Amz-SignedHeaders=host &X-Amz-Signature=5c1e...The signature is an HMAC over the method, the host, the path, the signed headers and the query parameters, computed with your secret key. The storage server recomputes it when the request arrives. Anything that changes one of those inputs - a different method, a different hostname, an extra signed header with another value - produces a different signature and a 403 SignatureDoesNotMatch.
Three things follow directly from this design:
- Generating a URL is local. The SDK does arithmetic with your secret; it does not ask the server. A URL for an object that does not exist is generated happily and fails only when used.
- The URL is a bearer token. Anyone who has it can use it, as many times as they like, until it expires. Treat a presigned PUT URL like a short-lived password.
- The access key ID is visible, in
X-Amz-Credential. The secret is not, and cannot be derived from the URL. Exposing the key ID is normal and harmless on its own.
Expiry: how long, and what it means#
X-Amz-Expires is the lifetime in seconds from X-Amz-Date. With Signature Version 4 the maximum is 604,800 seconds - seven days. The defaults differ by tool:
| Tool | Default expiry | How to set it |
|---|---|---|
boto3 generate_presigned_url | 3600 s | ExpiresIn= |
JS SDK v3 getSignedUrl | 900 s | { expiresIn: } |
AWS CLI aws s3 presign | 3600 s | --expires-in |
Pick the expiry by what the URL is for. An upload URL is used once, seconds after it is issued: five to fifteen minutes is plenty and limits the damage if it leaks. A download link embedded in a page that is rendered per request can be similar. A link sent in an email has to survive until the recipient opens it, which argues for days - but every day is a day the link works for anyone it is forwarded to.
The expiry is checked when the request starts, not when it finishes. On AWS, an upload that begins one second before expiry is allowed to complete even if it takes an hour; S3-compatible servers generally behave the same way. The timestamp check also means clocks matter: a URL generated on a machine whose clock is ten minutes fast is "not yet valid" for ten minutes. Hosted servers keep time; laptops and containers without NTP sometimes do not.
Generating URLs in Python and Node#
The client must be configured for the custom endpoint exactly as it would be for normal requests: endpoint, region, path-style. S3 from Node.js and Python has the full client setup; the presign calls are a few lines on top.
# Download link valid for 10 minutesurl = s3.generate_presigned_url( "get_object", Params={"Bucket": "media", "Key": "reports/2026/q3.pdf", "ResponseContentDisposition": 'attachment; filename="q3.pdf"'}, ExpiresIn=600,)# Upload URL for one key, valid for 5 minutesput_url = s3.generate_presigned_url( "put_object", Params={"Bucket": "media", "Key": "uploads/3f9c2a.jpg", "ContentType": "image/jpeg"}, ExpiresIn=300,)import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";import { getSignedUrl } from "@aws-sdk/s3-request-presigner";const getUrl = await getSignedUrl(s3, new GetObjectCommand({ Bucket: "media", Key: "reports/2026/q3.pdf" }), { expiresIn: 600 });const putUrl = await getSignedUrl(s3, new PutObjectCommand({ Bucket: "media", Key: "uploads/3f9c2a.jpg", ContentType: "image/jpeg" }), { expiresIn: 300 });From the shell, aws s3 presign s3://media/reports/2026/q3.pdf --expires-in 600 produces a GET URL with the same profile settings as every other CLI command. The CLI only presigns downloads; for an upload URL you need an SDK.
ResponseContentDisposition and its siblings (ResponseContentType, ResponseCacheControl) are worth knowing. They are signed into a GET URL and tell the server to override that header in the response - so the same stored object can be offered as an inline image on one page and as a download with a friendly file name on another.
The hostname must be the one the browser uses#
Because the host is signed, generate URLs with a client whose endpoint is the public address the browser will request. This catches people who run their app and storage on the same network:
- The app talks to storage on an internal address or the raw plain-HTTP port, and generates URLs with that client.
- The browser receives
http://203.0.113.10:PORT/..., which is either unreachable, blocked as mixed content on an HTTPS page, or both. - Rewriting the host in the URL by hand invalidates the signature.
If the app genuinely needs a different address for its own traffic, create two clients: one for the app's operations, one with the public HTTPS endpoint used only for presigning. Since generating a URL never contacts the server, the second client does not even need to be able to reach storage.
On RE:NODE's S3 storage the public address is the hostname you point at the plan's proxy slot, which serves HTTPS with a certificate issued and renewed for you. Sign URLs for https://s3.example.com and the browser, the certificate and the signature all agree.
Browser uploads: the full flow#
The pattern has four steps, and the third is the one that keeps your bucket clean.
- The browser asks your app for an upload URL, sending the file's name, size and type. The app checks the user is allowed to upload, checks the declared size and type against its limits, and generates a key itself - a random ID with an extension, never the user's file name.
- The app returns the key and a presigned PUT URL for it, signed with the declared
ContentType. - The browser PUTs the file to the URL with exactly that
Content-Typeheader. - The browser tells the app the upload finished. The app sends a
HEADrequest for the key to confirm the object exists and its size and type match what was declared, then records it in the database. Anything that never gets confirmed can be cleaned up later by listing the upload prefix and deleting keys older than a day that have no database row.
The browser side is a plain fetch:
const { key, url } = await fetch("/api/uploads", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: file.name, size: file.size, type: file.type }),}).then((r) => r.json());const put = await fetch(url, { method: "PUT", headers: { "Content-Type": file.type }, body: file });if (!put.ok) throw new Error(`upload failed: ${put.status}`);await fetch("/api/uploads/confirm", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key }),});If the app signed ContentType: "image/jpeg" and the browser sends image/png, or sends nothing, the signature fails. Sign the exact type you will send, and send it.
fetch gives no upload progress. For a progress bar, use XMLHttpRequest and its upload.onprogress event; the request is otherwise identical.
CORS: why the upload works in curl but not in the browser#
A page on https://app.example.com sending a PUT to https://s3.example.com is a cross-origin request. Before sending it, the browser makes a preflight OPTIONS request, and the storage server has to answer with headers allowing that origin, that method and the Content-Type header. If it does not, the browser blocks the upload and the console shows a CORS error - while the same URL works perfectly from curl, because curl does not do CORS.
On S3, the answer is a CORS configuration on the bucket:
{ "CORSRules": [ { "AllowedOrigins": ["https://app.example.com"], "AllowedMethods": ["GET", "PUT", "HEAD"], "AllowedHeaders": ["Content-Type"], "ExposeHeaders": ["ETag"], "MaxAgeSeconds": 3600 } ]}$ aws s3api put-bucket-cors --bucket media --cors-configuration file://cors.json --profile store$ aws s3api get-bucket-cors --bucket media --profile store$ curl -i -X OPTIONS https://s3.example.com/media/test \ -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: PUT"S3-compatible servers differ in how much of the bucket CORS API they implement, so test before you build on it: apply the rule, then send the preflight with curl and look for Access-Control-Allow-Origin in the response. If the bucket cannot be given a CORS rule, the fallback is to upload through your own app (the app receives the file and writes it to storage with the SDK), which costs app bandwidth and memory but needs no CORS at all. Do not reach for AllowedOrigins: ["*"] on a bucket you upload to; list the origins you actually serve.
Size limits and POST policies#
A presigned PUT cannot enforce a maximum size by itself. The app can refuse to issue a URL for a declared 5 GB file, but nothing stops the browser declaring 5 MB and sending 5 GB. That is why step 4 checks the real size with HEAD and deletes anything over the limit.
The S3 API has a second mechanism designed for browser forms: the presigned POST. Instead of a URL, the server issues a policy document listing conditions - the key or key prefix, the content type, and a content-length-range - and the browser submits a multipart form to the bucket URL with the policy and its signature as fields. The server rejects uploads that break any condition.
post = s3.generate_presigned_post( Bucket="media", Key="uploads/3f9c2a.jpg", Fields={"Content-Type": "image/jpeg"}, Conditions=[{"Content-Type": "image/jpeg"}, ["content-length-range", 1, 10 * 1024 * 1024]], ExpiresIn=300,)# post["url"] and post["fields"] go to the browser as a formIn Node the equivalent is createPresignedPost from @aws-sdk/s3-presigned-post. POST policy support varies more between S3-compatible servers than PUT does, so the same rule applies: test the limit by deliberately uploading a file that is too big and confirming it is refused. If it is not enforced, keep the HEAD check.
For files over a few hundred megabytes, a single PUT is fragile - one dropped connection restarts the whole thing, and one PUT is limited to 5 GB anyway. The S3 answer is a presigned multipart upload: the app calls CreateMultipartUpload, presigns one UploadPart URL per chunk, the browser uploads the chunks (retrying any that fail), and the app calls CompleteMultipartUpload with the part ETags. Libraries such as Uppy's S3 plugin implement the browser half. It is more moving parts, so use it only when files are genuinely large.
Security checklist#
- Generate keys on the server. Never let the browser choose the key; it could overwrite another user's file or write outside its prefix.
- Authorise before signing. The endpoint that issues upload URLs is the gate. Rate-limit it per user so it cannot be used to fill your storage.
- Short expiries for PUT. Five to fifteen minutes.
- Verify after upload. Size, type, and - for images - that the file actually decodes as an image before you show it to anyone else.
- Serve user uploads carefully. A user-uploaded HTML or SVG file served inline from a domain you control can run script in visitors' browsers. Serve such files with
Content-Disposition: attachment, or from a hostname separate from your app. - Keep the secret off the client. The browser receives URLs, never keys. Secrets live in the app's environment - see environment variables and secrets.
Private downloads follow the same logic in reverse: keep objects private, check permissions in your app when a user asks for a file, and answer with a short-lived presigned GET or a redirect to one. That way the storage never needs to be public, and access control stays in one place - your code.
@app.get("/files/<int:file_id>")def download(file_id): f = File.query.get_or_404(file_id) if f.owner_id != current_user.id: abort(403) url = s3.generate_presigned_url( "get_object", Params={"Bucket": BUCKET, "Key": f.key, "ResponseContentDisposition": f'attachment; filename="{f.safe_name}"'}, ExpiresIn=120, ) return redirect(url, code=302)The link in your page points at your own route, which never expires and is protected by your login; the presigned URL behind the redirect lives for two minutes and is never shown to anyone. Browsers follow the redirect without the user noticing, and a link copied from the address bar stops working almost immediately. Do not cache that redirect response, or a CDN or proxy may hand one user's URL to another - send Cache-Control: no-store with it.
FAQ#
Can a presigned URL be used more than once?
Yes. It is valid for any number of requests until it expires. A PUT URL used twice simply overwrites the object with the second upload. If you need single use, track issued keys in your database and refuse to record the same key twice.
Why does my presigned URL return SignatureDoesNotMatch?
The request differs from what was signed. The usual causes are a different hostname than the client was configured with, a Content-Type header that does not match the signed one, or a proxy rewriting the Host header. Generate the URL with the public endpoint and send exactly the signed headers.
What is the longest a presigned URL can last?
Seven days (604,800 seconds) with Signature Version 4. For anything longer, generate a fresh URL when the user asks for the file instead of storing URLs.
Do presigned URLs work with path-style endpoints?
Yes. The URL simply has the bucket in the path - https://s3.example.com/bucket/key?.... The client must be configured for path-style when it signs, as explained in path-style vs virtual-hosted URLs.
Should I make the bucket public instead?
For private user files, no: presigned URLs keep access control in your application. Public reads suit genuinely public assets, but they depend on what the storage provider supports, so check before you design around them.




Comments
Completely anonymous: no account, no email, no cookie. We store the name you type, the text and the time - nothing else. Links are limited and markup is not rendered.