CLUSTER · OBJECT STORAGE/ 2026-05-20/ 10 min read

Signed URLs aren't a fence

Every team that builds an upload flow eventually leans on presigned URLs and convinces themselves that the bucket is safe. Then we open a stranger's tax return because nobody told the link to expire.

The phrase "signed URL" sounds like it ends an argument. The link is signed. It's cryptographic. The bucket itself is private. What could possibly go wrong?

What can go wrong is that nobody on your team has written down the three things a signed URL is — and the three things it absolutely is not. So I'm going to do it here, with examples from real small-SaaS audits, and then show you the smallest change that buys back most of the safety you thought you had.

What a signed URL actually buys you

A presigned URL — S3, GCS, Azure Blob, R2, doesn't matter — is a request that has already been authorized by your service, packaged into a URL, and handed to a client. The signature says: this exact HTTP request, against this exact object, before this exact time, is allowed.

That's it. That is the entirety of the security model.

It does not say "only this user." It does not say "and only from this IP." It does not say "and revoke me if the user gets fired." It says "this request, this object, before this time." Once the URL leaves your server, you have no further control over who fetches the object. None.

If you internalize one thing from this post: a signed URL is a bearer token in URL form. It travels through every reverse proxy, every browser history, every chat client, every "share this with engineering" Slack thread, exactly the way a bearer token would. Treat it that way and most of the bugs we're about to discuss disappear.

Three patterns we keep finding

Across the last two years of small-SaaS audits, the three failure shapes below have come up more than every other file-storage finding combined. Each one is its own post in miniature.

Pattern A: the infinite TTL

The default lifetime in most SDKs is "until you set one." So the developer who never sets one ends up with 7 days, or sometimes 7 hours of an SDK upgrade away from being 7 years. We have, on more than one occasion, opened a presigned URL pasted into a Notion doc eleven months after the team stopped using that bucket structure.

The fix is to pick a TTL that matches the use case, not the SDK default. For a download link the user just clicked, 60 seconds is generous. For an email attachment link, 15 minutes is generous. For "the user might come back tomorrow," it's not a signed URL — it's a server-side route that mints a fresh link on demand. (See the IDOR post for why every server-side route still needs a tenancy check.)

Pattern B: the "revoked but signed" trap

A user is removed from a team. Their session is terminated. Your access-control middleware does the right thing. And yet, the presigned URL they got at 11:00 AM is still valid at 11:05 — because you set a 24-hour TTL, and nothing about removing the user actually revokes the URL.

Why? Because the signature is a function of the object key and the expiry. The user's identity is not in the signature. Cloud providers do not check your application's session table when they verify a signature. They cannot. That's the whole point.

The fix is to either (a) keep TTLs short enough that "revoke" means "wait a minute," or (b) move the file behind a server-side route that does an explicit tenancy + session check on each request and proxies the bytes itself. We default to (a). For "this is your tax return" data we recommend (b), full stop.

Pattern C: the private bucket + public CDN double-shuffle

This is the one that hurts. The team puts files in a private bucket — good — and then puts a CDN in front of it for performance — also good — and configures the CDN to cache by URL. The CDN happily caches the signed response with the signature baked into the cache key. Someone else hits the same URL after the user's session has rotated, and the CDN serves the cached file from edge.

In one variant we found last year, the CDN was caching by path, ignoring the query string entirely. The signed URL was effectively a public URL the moment the first user clicked it. The bucket was tight; the CDN was the leak.

This is not a misconfiguration of the CDN, really — it's a model mismatch. The CDN was sold "make this faster." It is doing exactly that. The team forgot to tell it which parts of the URL are the cache key and which parts are credentials.

What "the right way" looks like

For most small products, the right default for sensitive files is not a signed URL at all. It is a server-side download route. The route checks the session, checks the tenancy (see the billing-endpoint walkthrough for why this query has to include the tenant join), and then streams the bytes from object storage to the client itself.

"But that costs me egress!" Yes. Approximately one mortgage payment a year, in our experience, for a small product. Compared to the cost of accidentally publishing a customer's data, this is the bargain of the century.

Use signed URLs when:

Use server-side routes when:

What we look at on the audit

For a small product with a file feature, the storage section of the first audit typically takes about half a day, and follows this shape:

  1. List every code path that creates a presigned URL. Note the TTL. Note the operation (GET/PUT/POST). Note whether the key is user-controlled.
  2. For each, ask: what's the worst that happens if this URL is posted publicly? If the answer is "we leak someone's invoice," it's a finding regardless of TTL.
  3. Test revocation. Generate a URL as user A. Remove A from the team. Try the URL. If it works, the URL is a long-lived bearer token disguised as a permission.
  4. Inspect any CDN in front of storage. What's the cache key? Does it include the signature? Does it include the path only? Does the cache obey Cache-Control: private?
  5. Look for the "share" feature that almost certainly leaks signed URLs into chat clients, ticket systems, or screenshots.

Most of these are 10-minute checks. The expensive part is writing the finding so engineers see why it matters and not just that it's a "P3 IDOR-adjacent" line in a tracker.

What to ship this week

If you have a file feature and you've read this far, three moves, in order:

  1. Audit the TTLs. Anything over five minutes for a download-style URL is a code smell. Anything over an hour is a finding.
  2. For your "this represents user data" files, pick one. Either move them behind a server-side route, or document — in code — why a presigned URL is acceptable for this object class.
  3. If you have a CDN, write a single line of test code that asserts the cache key includes the signature, and run it in CI. If you can't, your CDN is caching credentials.

This is one of those areas where you can't outsource the thinking to your provider. AWS is doing exactly what you asked. The fence is real. The fence is also not what you thought it was.

Cyclopes
Independent security practice — small product audits.

Read next

Worried about your upload flow?

The free heads-up includes a focused look at your presigned-URL handling. If your TTLs and tenancy checks hold up, we'll tell you. If they don't, we'll show you exactly where.

Request a free check