How Safe Is a 60-Minute Signed URL? The Link Is the Credential
You added a CORS rule to a private bucket and then wondered whether you had just made every customer invoice public. You had not — CORS lives in the browser and never decides who may fetch a file. The signed URL does, and it is a bearer token: whoever holds the string gets in, with no login, no session, and no way to cancel it. The expiry is the only dial you get, so here is how to set it on purpose.
Here is the moment that starts this.
You build an app. Your users upload private files — invoices, scans, contracts. You keep them in a private bucket in object storage. Your web page needs to show one of them, and the browser refuses: blocked by CORS. You search the error, you find the answer, you paste a CORS rule onto the bucket. The error goes away.
And then you feel uneasy. You just changed a setting on the bucket that holds every customer invoice you have. Did you make them public?
No. You did not. But the reason you did not is more interesting than the answer, because the thing that is guarding those files turns out to be much weaker than most people assume.
1. CORS was never the door
CORS — cross-origin resource sharing — is a rule that lives in the browser. It tells a browser which web pages are allowed to read a response using JavaScript. That is all it does.
It does not decide who may fetch a file. It decides which page may look at what came back. And only a browser obeys it — a script, a CLI tool, a program on somebody else's machine never even reads the rule.
Read a CORS policy again and look for a person:
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["GET"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3000
}
Allowed origins. Allowed methods. Allowed headers. A cache time. There is no field for a user. No field for an account. No field for who. A CORS rule cannot say who, because saying who is not its job.
The proof is one curl away. Point a server at that policy, then ask for the file three times — once pretending to be the allowed page, once pretending to be a page that is not allowed, once with no Origin at all:
Origin: https://app.example.com → 200 OK bytes returned
Origin: https://evil.example.net → 200 OK bytes returned
(no Origin header) → 200 OK bytes returned
Three times the bytes came back. The policy changed nothing for a client that is not a browser. Adding your domain to a CORS rule did not open a door — there was never a door there.
The door is somewhere else.
2. Two doors: the mental model
Picture two doors.
On the left, a doorman. Everyone who wants in shows a photo card. He looks at the face, looks at the card, decides. He knows who went in. And tomorrow you can tell him one particular person is no longer welcome, and he will turn that person away.
On the right, no doorman — just a small card reader on the wall, and on the ground in front of the door lies a key card. The card has no name on it. It does not care who picks it up. Whoever holds it walks in. One single thing protects that door: the card stops working at a set time.
A signed URL is the right-hand door. It is not the left one. And the clock is the only dial you get.
3. Signing is arithmetic, not registration
The code that makes the link is one call:
url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": "invoices-private", "Key": "u/4192/inv-8871.pdf"},
ExpiresIn=3600, # one hour
)
An operation (get_object = read), a bucket and key that together name exactly one file, and ExpiresIn in seconds. 3600 is the sixty minutes in the title.
Now the part that surprises people the first time: this call does not talk to the object store at all. Nothing is registered. No row is written anywhere. Break every network socket in the process first, so any outgoing connection would crash immediately — the link is still produced. It is pure arithmetic, done on your own machine.
Which means the object store has never heard of this link, and it will still accept it. Hold on to that, because it explains everything that follows.
What the signature covers — and what it does not
The signature is a fingerprint over a short list of facts. Change any one of them and the fingerprint changes.
| Covered by the signature | NOT covered |
|---|---|
| The bucket | Who you are |
| The key (exact file name) | Which computer you are on |
| The method (read or write) | How many times it is used |
| The moment it was signed | Any concept of "cancel" |
| How many seconds it stays good | The person who first received it |
| A secret key that never leaves your server | Whether that person still works there |
The right-hand column is the whole story of this article.
4. Why the store does not need to have heard of your link
When the request arrives, the object store does not look anything up. It recomputes:
- Take the parts of the request it can already see — method, path, query, host.
- Put them in a fixed order. That block has a name: the canonical request.
- Hash it, and drop the hash into a second small block with the date and the region.
- Build a signing key by running a chain of keyed hashes from the same shared secret.
- Sign the block with that key. Out comes a string.
If that string equals the signature that arrived in the URL, the answer is yes.
Look at what is not in that list. No database. No session table. No lookup of any kind. Write the whole chain by hand and compare it against the real library: same input, same string, character for character.
That is why signed URLs scale so beautifully — and why they cannot be revoked. Verification is a pure function of the request.
5. The sentence that matters: the URL is the credential
It is not a pointer to a permission that lives somewhere else. It carries its own permission inside itself. Anybody who holds the string is authorised. No login. No session. No idea who they are.
If you have met access tokens and refresh tokens, this is the same family: it is a bearer token. Bearer means the holder — whoever holds it, gets in. The one difference is that a normal token usually travels in a header, where it is a little harder to leak by accident.
This one travels in a URL. And URLs get copied.
Put a stranger on the diagram and nothing about the system changes. Same three parts, same arrows. The only new thing is that the string got out. The stranger asks the object store for the file, and the file comes back — and the arrow is identical to the legitimate one. The store cannot tell them apart, and it is worth being precise about why: not because it is badly built, but because there is nothing in the request that would tell it apart. Same signature, same bucket, same key. Same arithmetic, same yes.
That is not a bug. That is the design working exactly as specified.
6. How URLs actually leak (no attacker required)
Almost never because somebody attacked you. They get out because ordinary software does ordinary things:
- A page links onwards and the browser sends the
Refererheader — the whole URL rides along to the next site. - Somebody pastes the link into a support chat to show the problem.
- It sits in browser history on a shared computer.
- It lands in a proxy, load-balancer or CDN log.
- It appears in a crash report sent to your error tracker.
- Somebody takes a screenshot with the address bar in it.
- A user forwards the email that contained it.
None of that is an attack. That is an ordinary working day. And every one of those copies keeps working.
7. The arithmetic of the leak window
This is where a number in a config file turns into a real thing.
Your setting says 3600 — one hour. Say the link leaks three minutes after it was made. It keeps working for the remaining fifty-seven minutes, for anybody who has it. That is your leak window.
Shrink the setting to 60 and the worst case drops to one minute. But the same person watching for an hour now needs sixty links instead of one. With 5,000 concurrent viewers that is 300,000 signing requests an hour instead of 5,000.
The dial has two ends, and both ends cost something.
| Short expiry (60 s) | Long expiry (1 h) | |
|---|---|---|
| Leak window | at most one minute | a full hour, for anyone on earth holding the string |
| Signing load | one request per view, continuously | one request, once |
| Large downloads | can fail part-way through | fine |
| Video seeking | needs fresh links mid-playback | fine |
| Tab left open overnight | broken images in the morning | fine (and that is the problem) |
There is no correct answer here. There is only the file. A public marketing image can have a day. A medical scan should have a minute.
8. The shape of the fix — and notice most of it is not about signing
@app.get("/files/{file_id}/url")
def get_file_url(file_id: str, user = Depends(current_user)):
# 1. who is asking — the ordinary session check you already have
# 2. are they allowed THIS file — a real ownership check in your DB,
# not a guess based on the file name
obj = db.files.get(file_id)
if obj.owner_id != user.id:
raise HTTPException(403)
# 3. only now sign, and sign SHORT
url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": obj.bucket, "Key": obj.key},
ExpiresIn=60,
)
# 4. return it as JSON to a page that already has a session,
# not as a link that gets shared around
return {"url": url}
This is where your identity check lives. It lives here, before the signature exists — because after the signature exists, there is no identity check anywhere.
9. You cannot cancel a signed URL
This is the part people find out too late. There is no delete button, because the object store never stored it. It is not in a list anywhere. It is arithmetic, and you cannot undo arithmetic.
There is exactly one emergency switch: rotate the signing key. The store then computes a different answer, so the leaked link stops working — and so does every other link you have ever handed out, to every user, all at once.
That is an emergency switch, not a control. Which is why the expiry is not a small detail: it is the only revocation you have, and you decide it in advance.
The same problem, wearing an AI hat
If you are building LLM or model-serving infrastructure, you are handing out signed URLs constantly — often without noticing, and often with the default hour.
- Generated artifacts. An image, a video, a rendered PDF, a transcript. Your inference API returns a link rather than base64 bytes, because a 4 MB image does not belong in a JSON response. That link is a bearer token, and it is now sitting in your API client's logs.
- RAG source documents. The classic "cited source" link next to an answer. Every citation your assistant renders is a presigned URL to a private corpus document, and citations are exactly the thing users copy into Slack to show a colleague.
- User uploads into the pipeline. Presigned
put_objectso the browser uploads straight to storage instead of through your API. That is a write credential in a URL — anyone holding it can overwrite that key until it expires. - Model weights and datasets. A checkpoint URL handed to a GPU worker is a long-lived credential to your most expensive asset, and it typically ends up in a job spec, an environment variable, and a container log.
- Agent tool calls. The genuinely new hazard: when an agent fetches a signed URL, the string enters the model's context, which is then written to a trace store, an eval dataset, a prompt-caching layer, and possibly a third-party observability vendor. A prompt-injected page that gets the model to echo its context back has just exfiltrated a working credential.
The mitigation is the same everywhere, and it is the boring one: sign short, and sign per-request. For agent and RAG paths especially, prefer a 30–60 second expiry issued at render time over a one-hour link baked into a stored response — because in an AI stack, everything gets logged, and the log is forever while the credential should not be.
The verdict
Your CORS setting decides which web page is allowed to read the response. Your signature decides who is allowed to read the object. Only one of those is a security boundary, and its whole strength is a number in a config file.
The bucket staying private — no public address on it at all — is doing far more work than the CORS line everybody stares at.
A 60-minute signed URL is exactly as safe as the worst thing that happens to that string within 60 minutes.
So go and find that number in your own code today. Ask what happens if that exact string ends up in a chat window. Then set it on purpose.
Watch the reel: Your signed URL is a bearer token — and it has no cancel button · Full 14-minute breakdown: How Safe Is a 60-Minute Signed URL?