Skip to content
Back to field notes
SupabaseJune 6, 20266 min read

Signed URLs in Supabase: when they protect files and when they do not

A signed URL stops a stranger from guessing the path to a private file, but it does not stop a leaked link, a too-long expiry, or an unprotected generation endpoint. This note maps what signed URLs cover before you ship.

SupabaseStorageAccess control

What a signed URL actually protects

If you have already made your sensitive Supabase buckets private, signed URLs are the next thing to get right, and the place where the threat model is most often misread. A signed URL is a time-limited link Supabase generates for one object in a private bucket. It carries a token that the storage layer checks before serving the file, and it expires after the window you set. That is the whole mechanism, and the entire point of getting Supabase signed URLs security right is knowing exactly which problem that mechanism solves.

What it solves is unauthenticated access by path. A private bucket does not serve objects at the public path, so an anonymous request to a guessed object name returns an error, not the file. The signed URL is the one path that does come back, and only for as long as the token is valid. So the thing a signed URL prevents is narrow and real: a stranger cannot reach a private object by guessing or enumerating its name, the way they can with a public bucket.

Notice what that does not say. A signed URL is a bearer token in a query string. Anyone holding the link can use it until it expires, with no further check on who they are. The protection lives in two things only: the secrecy of the link and the length of its lifetime. Everything that can go wrong with signed URLs is a failure of one of those two, or a failure at the endpoint that hands them out.

  • What it prevents: anonymous access to a private object by guessing or enumerating the path. Without a valid token, the storage layer refuses.
  • What it does not prevent: use of the link by whoever ends up holding it. A signed URL authenticates the request as token-bearing; it does not authorize the human on the other end.
  • This is the line between authentication and authorization. The token proves the request carries a valid grant. It does not prove the person using it is the one you meant to grant access to.

Why AI-built apps lean on signed URLs the wrong way

When you ask an AI tool to serve private files, signed URLs are the pattern it reaches for, which is correct. The trouble is in the defaults it picks while doing it. Three of them show up again and again.

The first is a long expiry. Generating a signed URL with a one-week or one-year lifetime makes the demo reliably clickable: the link works tomorrow, it works in a screenshot, it works when you test it next month. A long-lived signed URL is, for that whole window, functionally a public link to a private file. The convenience that makes it good demo code is exactly what makes it a leak waiting for a destination.

The second is putting the link somewhere it persists. A signed URL pasted into an email, logged by an analytics tool, captured in a server log, or stored in a database row outlives the moment it was meant for. The token is in the URL, so anywhere the URL travels, the grant travels with it.

The third, and the one a scan cannot see, is the endpoint that generates the link. AI-generated code commonly adds a route that returns a signed URL for a requested object, and just as commonly forgets to check that the caller is allowed that specific object. If the route signs whatever object id it is handed, an authenticated user can ask for someone else's file and get a working link. The signed URL is doing its job perfectly; the authorization check before it was never written.

Check expiry, link handling, and the generation route on your own app

You can confirm all three on your own project without attacking anything.

Check the expiry first. Find where your code calls createSignedUrl and read the expiry argument (it is seconds). A value like 60 or 300 is a short, sensible window. A value in the tens of thousands or higher is days, and a year is over thirty million. Then open one of your app's real file links in DevTools, copy the full signed URL, wait past the expiry, and request it again in a fresh private window. The safe result is an expired-token error. The suspicious result is the file still loading long after the user's session would have ended.

Check where the link goes. Look at whether your signed URLs land in places that persist: outbound emails, logs, analytics events, or a column in your database. A signed URL is a credential; treat finding one in a log the way you would treat finding a session token there.

Check the generation route. If your app exposes an endpoint that returns a signed URL for a given object, sign in as one ordinary user and ask that route for an object id that belongs to a different user. The safe result is a refusal. The suspicious result is a working signed URL for a file that user should never reach. That is an authorization gap, the same class as an insecure direct object reference, and it sits one layer behind the storage check.

  • Safe: short expiry (seconds to a few minutes), links not written to logs or emails, and the generation route refuses object ids the caller does not own.
  • Suspicious: multi-day or multi-month expiry, signed URLs sitting in logs or sent in mail, or a route that signs any id it is handed.

Fix the lifetime and the endpoint, not just the bucket

The fixes are small and they target the two real weaknesses plus the route in front of them.

Set the shortest expiry the use case tolerates. Generate the signed URL on the server, for the authenticated owner, at the moment it is needed, with a lifetime measured in seconds or a few minutes rather than days. A link that expires before it can be forwarded or scraped is a much smaller exposure than one that lives for a week. Never bake a long-lived signed URL into client code or a stored record.

Authorize the object before you sign it. On the generation route, confirm the authenticated caller is actually allowed that specific object before calling createSignedUrl. Pair that with Supabase storage access policies so the underlying authorized-request path enforces the same rule, rather than relying on the route alone. The signed URL should be the last step after the access decision, not a substitute for it.

What this does not cover: signed URLs are not a substitute for marking sensitive buckets private in the first place. If the bucket is public, the signed URL is irrelevant because the object is already reachable at its plain path; close that first and see the Supabase storage bucket public access note. And shortening expiry does not retroactively protect links you already issued with long lifetimes, those remain valid until they age out.

A signed URL is access control by possession and clock, nothing more. It does not know who is holding the link, it does not revoke early once issued, and it does not check authorization on its own. The decision of who may read which object lives in your generation endpoint and your storage policies, not in the token.

Where VibeCodeGuard fits, and what it does not prove

VibeCodeGuard's Launch Check works at the bucket level from the public surface. It probes your storage endpoints and reports whether a bucket is publicly readable without authentication, which is the prerequisite mistake that makes signed URLs moot. If a bucket that should be private is serving objects at the public path, the scan flags it with severity and fix direction. That is the part an outside visitor can actually observe.

What it cannot see is the signed-URL logic itself. The expiry you set, whether links leak into logs or emails, and whether your generation endpoint authorizes the caller against the requested object are all decisions inside your code and config. None of them are visible from a clean external scan, and confirming them is manual review, not a public-surface signal.

Next step: open the code that calls createSignedUrl, drop the expiry to the shortest your use case allows, and confirm the generation route refuses objects the caller does not own. Then run a focused Launch Check against the public URL before sharing it, so a bucket that is still public shows up in your scan rather than in someone else's.

A clean bucket-level result does not prove your signed URLs are safe. The scan confirms the bucket is private; it does not read your expiry values, trace where links travel, or test the route that issues them. Treat a flagged public bucket as a launch blocker, and treat signed-URL expiry and endpoint authorization as a separate review item you do by hand.

> launch check

Scan the public surface before launch.

Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.