Supabase
Serve Private Files with Supabase Storage Signed URLs
Keep a Storage bucket private, check the user on the server, and hand the browser a short-lived signed URL.
- Supabase
- Storage
- Security
- Next.js
On this page
Some files are public on purpose: a blog cover, a company logo, an open-source screenshot. Everything else should sit in a private bucket. Invoices, ID scans, project exports, and draft video stills are private even if the product page that links to them is not. A private bucket rejects anonymous reads. A signed URL is a temporary exception you create after you have decided this user is allowed to see this object.
The mistake is to make the bucket public because the img tag was easier, or to sign every path the browser asks for. Signing is not authorization. Authorization is the check you run before you call createSignedUrl. The signature only proves that your server asked Storage for that path at that moment.
Create the bucket as private
In the Supabase dashboard, or in SQL, create the bucket with public set to false. Decide a path layout that includes the owner, such as userId/invoiceId.pdf. You will use that prefix in policies and in the server check. Do not store the object name as a guessable sequence if the file is sensitive. A random id is enough.
- Public buckets are for assets you could email to a stranger.
- Private buckets are for anything tied to an account.
- The service-role key can read private objects. It does not belong in the browser.
- A signed URL is a bearer token for one object until it expires. Treat the URL like a secret.
Let the owner upload, not the world
Storage policies are row policies on storage.objects. A tight upload policy says the user can insert into this bucket only when the first folder matches their user id. That stops a signed-in user from overwriting someone else's file by choosing a path. Downloads can be denied entirely for the anon and authenticated roles if you want every read to go through a signed URL created on the server.
create policy "users upload own folder"
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'private-docs'
and (storage.foldername(name))[1] = auth.uid()::text
);
Sign on the server after a check
A route handler loads the session, loads the row that points at the object, and compares the row's owner to the user. Only then does it ask Storage for a URL that lives for a minute or two. Sixty seconds is enough for an img tag or a redirect. An hour is reasonable for a download the user might retry. A year is a public file with extra steps.
import { createClient } from "@supabase/supabase-js";
export async function GET() {
const user = await requireUser();
const path = await pathForUser(user.id);
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
);
const signed = await supabase.storage
.from("private-docs")
.createSignedUrl(path, 60);
if (signed.error || !signed.data) {
return new Response("Not available", { status: 404 });
}
return Response.redirect(signed.data.signedUrl, 302);
}
declare function requireUser(): Promise<{ id: string }>;
declare function pathForUser(userId: string): Promise<string>;
The non-null assertions in that sample are a sketch, not a style to copy into production. Read the environment at startup and fail the deploy if the service-role key is missing. Never prefix that key with NEXT_PUBLIC. The browser only receives the signed URL, and only after requireUser and pathForUser agree.
Expiry, caching, and logs
Do not cache the redirect at the CDN. A cached 302 would hand the same signed URL to the next visitor. Send the file response as private, or generate the URL on each request. If you embed the URL in HTML that is itself cached, you have published the file for as long as that HTML lives. Generate the URL when the user opens the document, not when you render a shared page.
- Prefer a short TTL and a fresh signature over a long URL stored in the database.
- Delete the object when you delete the account, or the signature check is protecting a file you meant to erase.
- Log the object path and the user id. Do not log the full signed URL.
- If a link leaks, wait for expiry or rotate by moving the object to a new path.
Public blog images do not need any of this. Put those in a public bucket or in your own /public folder and cache them. Signed URLs are for files whose absence should look like a 404 to everyone except the owner. When you are unsure which bucket a new upload belongs in, choose private and promote it later.
Uploads need a size and a type
A signed download is only half the feature. The upload that created the object should reject a file you will not want to store: the wrong MIME type, a size above the limit you can afford, and a path that escapes the user's folder. Check those on the server even if the browser input has an accept attribute. Store the path on a row the user owns, and sign from that row, not from a path query parameter. A handler that signs whatever string arrives in the URL is a private bucket with a public index. When you delete a document, delete the row and the object in the same action. An orphaned object remains readable to anyone who saved the old signed URL until that URL expires, and it remains in the bucket after that, waiting for the next signature.
