← All posts
·6 min read·GuardLayer

Supabase signed URLs: expiry, leaks, revocation

SupabaseStorageSigned URLsSecurity

A Supabase signed URL is a bearer token in a query string: anyone who holds it can fetch the object until expiresIn elapses, regardless of who they are or whether the user who generated it still has access. It can't be revoked on demand — Supabase's docs say to contact support — so the only real control you have is a short expiry.

Signed URLs are the correct way to serve files from a private bucket. They're also routinely treated as "the private version of a public URL," which they aren't. Understanding the difference is what stops a leaked link from being a permanent one.

How long should a Supabase signed URL last?

As short as the operation needs — seconds to minutes for a download, not days. The reason is revocation, or rather the absence of it:

Storage signed URLs are signed with a dedicated internal key that is separate from your project's Auth JWT signing key. [...] If you need to revoke signed URLs, contact Supabase support.

Supabase docs, Serving assets from Storage

Read that second sentence as an operational constraint. There is no revokeSignedUrl(). Rotating your Auth keys does nothing — signed URLs remain valid until their expiry time regardless of Auth key changes, because they're signed with a different key entirely. Deleting the user doesn't help. Revoking their session doesn't help. The URL keeps working.

So expiresIn isn't a convenience setting. It's your entire incident-response window.

// A download link the user clicks now.
const { data } = await supabase.storage
  .from("invoices")
  .createSignedUrl(`${userId}/${invoiceId}.pdf`, 60); // 60 seconds

Sixty seconds is plenty for a click-to-download. The one-week expiry copied from a tutorial means a URL pasted into a Slack thread, a support ticket, or a browser history sync is live for a week — to everyone who can read that thread.

Where a link genuinely must survive longer (an emailed receipt), don't extend the expiry. Email a link to your app, authenticate the request, and mint a fresh short-lived signed URL server-side on each visit.

The shortcut that skips signed URLs entirely

The most common resolution to "signed URLs are annoying" is to stop using them:

-- Bucket for user-uploaded invoice PDFs.
insert into storage.buckets (id, name, public)
values ('invoices', 'invoices', true);
guardlayer scan · supabase/migrations/20260803150000_storage_buckets.sqlLive engine output
Check failed
75/100 · B
  • Criticalsupabase/migrations/20260803150000_storage_buckets.sql:2

    Public storage bucket

    Set public: false and serve files through signed URLs (createSignedUrl) or RLS-protected access. Only keep a bucket public for genuinely public assets.

A public bucket serves every object to anyone with the URL — no token, no expiry, no auth. Object paths in a public bucket are also enumerable through the storage API, so "the URL is unguessable" doesn't hold either. This is the silent data leak public buckets cause, and for user-uploaded documents it's rarely the right trade.

Keep public: false and pay the signed-URL cost. Reserve public buckets for genuinely public assets — marketing images, avatars you'd put on a landing page.

Signed URLs don't replace RLS

Here's the failure that surprises people: createSignedUrl signs whatever path you hand it, and the signature proves only that your project issued it. It does not check that the caller owns the file.

// app/api/download/route.ts — an IDOR waiting to happen.
export async function POST(request: Request) {
  const { path } = await request.json();

  const { data } = await supabase.storage
    .from("invoices")
    .createSignedUrl(path, 60); // path is whatever the client sent

  return Response.json({ url: data?.signedUrl });
}

Send path: "other-user-id/their-invoice.pdf" and the endpoint happily signs it. The signature is valid. The file is delivered.

Two layers fix this properly:

1. Enforce ownership in the route. Never sign a client-supplied path directly — derive it from the authenticated user, or verify ownership first:

import { z } from "zod";

const Body = z.object({ invoiceId: z.string().uuid() });

export async function POST(request: Request) {
  const parsed = Body.safeParse(await request.json());
  if (!parsed.success) return new Response("Bad request", { status: 400 });

  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return new Response("Unauthorized", { status: 401 });

  // Path is derived from the session, not from the request body.
  const path = `${user.id}/${parsed.data.invoiceId}.pdf`;

  const { data, error } = await supabase.storage
    .from("invoices")
    .createSignedUrl(path, 60);

  if (error) return new Response("Not found", { status: 404 });
  return Response.json({ url: data.signedUrl });
}

Taking the shape of the request body on trust is its own recurring bug — see validating the request body in a Next.js API route.

2. Put RLS on storage.objects. If your signing happens with the anon key under a user session, policies apply. Store files under a user-id prefix and scope by the first path segment:

create policy "users read their own invoices"
  on storage.objects
  for select
  to authenticated
  using (
    bucket_id = 'invoices'
    and (storage.foldername(name))[1] = (select auth.uid())::text
  );

The catch worth stating plainly: if your API route signs with the service_role key, it bypasses these policies entirely — that key ignores RLS by design, which is why it must never leave the server. Server-side signing puts the ownership check back on your code. Do both, and the app layer being wrong isn't automatically a breach.

A 60-second self-check

# 1. Long expiries — anything over ~3600 deserves a reason
grep -rn "createSignedUrl" app/ lib/ --include="*.ts" --include="*.tsx"

# 2. Public buckets in migrations or provisioning code
grep -rn "public: true" lib/ app/ ; grep -rni "storage.buckets" supabase/migrations/

# 3. Client-supplied paths reaching the signer
grep -rn -A5 "createSignedUrl" app/api/ | grep -i "body\|params\|request"

If a path in command 3 traces back to request.json() without an ownership check between, that endpoint signs files for anyone who can name them.

GuardLayer's supabase/public-storage-bucket rule catches the bucket half of this — public: true in both the JS API and SQL migrations — on every push. The IDOR half is a code-review item; the ownership check has to be read, not pattern-matched.

FAQ

Can I revoke a Supabase signed URL? Not through the API. Supabase's docs direct you to contact support. Plan around short expiries instead — that's the only revocation you control.

Does rotating my API keys invalidate existing signed URLs? No. Storage signed URLs use a dedicated signing key separate from your Auth JWT keys, so they stay valid until they expire.

What's a sensible expiresIn? 60 seconds for an immediate download, up to an hour for something the user might open in a new tab. Days or weeks only for content you'd accept being fully public.

Do RLS policies apply to signed URL generation? Yes when you sign with a user's session under the anon key — storage.objects policies are enforced. No when you sign with the service_role key, which bypasses RLS.

Is a signed URL safe to put in an email? Only if the expiry is short. Email is stored, forwarded, and indexed. Prefer a link to an authenticated page in your app that generates a fresh URL on load.

Can someone list files in a private bucket? Listing is governed by RLS on storage.objects, so without a SELECT policy they can't enumerate. Public buckets have no such protection.

Catch this before it ships — free

GuardLayer scans every push for this and 26 other Next.js + Supabase issues, with the exact fix inline.

No signup, no card — your code is scanned in memory and never stored.

Keep reading