HomeGuidesSupabase signed upload URLs and list policies, tested
Guide
Supabase signed upload URLs and list policies, tested
What createSignedUploadUrl checks, what the token still allows after that, and how SELECT policies control list, download and signed URLs. Tested locally.
Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.
createSignedUploadUrl looks like the safe way to let a browser upload a file: your code creates a URL, the browser sends the file there, done. The part that is easy to miss is when Supabase checks your policies. It checks them once, when the URL is made. After that the token is the permission, for anyone who has it, until it expires.
This guide covers what the signed upload token allows, which SELECT policy list(), download() and createSignedUrl each need, what a public bucket exposes, and how to allow downloads without letting users list a folder. For basic bucket privacy see Supabase Storage bucket security. For upload and upsert errors on normal uploads see the Storage upload RLS error guide.
Tested on October 27, 2026. We ran the open source Supabase Storage server v1.84.25 from source with its 75 migrations on PostgreSQL 17.11, using @supabase/supabase-js 2.118.0, two signed in test users, an anon client and the service role. The server used its local file backend instead of S3, and there was no API gateway in front of it. We did not test on a hosted Supabase project, which may run a newer Storage version and sets its own signed upload lifetime. Every claim below is either something we reproduced or a quote from the docs and source linked at the end. VibeLocked is independent and not affiliated with Supabase.
The short version
createSignedUploadUrlchecks your INSERT policy when it creates the token, and only then. The upload itself skips RLS.- The token is a bearer token for one bucket and path. Anyone holding it can upload without signing in.
- A token made with
upsert: truecan overwrite that file again and again until it expires. A token made without it can upload again after the file is deleted. - Removing a policy does not cancel tokens already issued. There is no revoke call. Expiry is the only limit.
- Bucket MIME type and size limits still apply to signed uploads.
list(),download()andcreateSignedUrlall use the SELECT policy. Without one,list()returns[]with no error and the others sayObject not found.- Public buckets serve every file to anyone with the path, but do not list. A broad SELECT policy added to make
list()work exposes every file name. storage.allow_only_operation()andstorage.allow_any_operation()let one SELECT policy allow downloads but not listing.
What we tested
Two users, Alice and Bob, each with a folder named after their user id, such as avatars/<alice id>/doc.txt. Every policy used the owner folder check from the bucket security guide:
bucket_id = 'avatars'
and (storage.foldername(name))[1] = (select auth.uid())::text
We created signed upload URLs as Alice, as anon and with the service role, uploaded with and without a user session, reused and edited tokens, removed policies after signing, changed the server's token lifetime, and then repeated the list, download and sign calls under different SELECT policies in a private and a public bucket. That came to 72 checks across three scripts, with the results shown below.
What createSignedUploadUrl checks, and when
In the server source, signUploadObjectUrl starts with the comment "check if user has INSERT permissions" and calls the same upload permission test a normal upload uses. That test runs the insert for the path inside a transaction and rolls it back. Then the server signs a token holding the owner, the bucket and path, the upsert flag and a scope of upload.
| Signed as Alice with | Result |
|---|---|
| No policies | new row violates row-level security policy (403) |
| INSERT policy, path in her folder | Token created. No row in storage.objects yet |
| INSERT policy, path in Bob's folder or at the bucket root | new row violates row-level security policy |
| INSERT policy, path that already has a file, no upsert | The resource already exists (409) at signing time |
INSERT policy only, upsert: true |
new row violates row-level security policy |
INSERT and UPDATE, no SELECT, upsert: true |
new row violates row-level security policy |
INSERT and SELECT, upsert: true, new path |
Token created |
INSERT, SELECT and UPDATE, upsert: true, existing file |
Token created |
Anon key, policy is to authenticated |
new row violates row-level security policy |
| Service role, no policies at all | Token created. The uploaded row had owner_id NULL |
So signing with upsert: true needs the same policies as an upsert, which the upload RLS guide breaks down. The service role skips every policy, as Supabase's access control docs say: "Service keys entirely bypass RLS policies."
What the token allows after that
The upload route, uploadSignedObject.ts, verifies the token and then writes the file with request.storage.asSuperUser(). No RLS check happens on upload. Supabase's createSignedUploadUrl reference says the same in plain words: "Signed upload URLs can be used to upload files to the bucket without further authentication."
With a token Alice created for <alice id>/one.txt |
Result |
|---|---|
| Upload with only the anon key, no user session | Worked. owner_id set to Alice's id from the token |
| Same token, same path, second upload (token without upsert) | The resource already exists (409) |
Same, but the upload call sets upsert: true |
Still 409. The token's own upsert flag wins |
| Same token after Alice deleted the file | Worked. The file was back with new content |
| Same token on another path, Bob's folder, or another bucket | Invalid signature (400) |
Token with the payload edited to upsert: true |
signature verification failed (400) |
| Upload token used on the signed download route | Token is not scoped for download (400) |
| Token used after Alice's INSERT policy was dropped | Worked, while Alice's direct upload was now denied |
| Token used after Alice's own session JWT expired | Worked. Her direct upload got "exp" claim timestamp check failed |
With an upsert token for Alice's existing file <alice id>/doc.txt:
- It overwrote the file twice in a row.
- Bob used the same token and replaced Alice's file. The response was a success. The content became Bob's and
owner_idstayed Alice's, so nothing in the row showed who wrote it. - After we dropped the UPDATE policy, the token still overwrote the file.
Supabase's uploadToSignedUrl reference confirms the upsert rule: "The upsert option has no effect here. To enable upsert behavior, pass { upsert: true } when calling createSignedUploadUrl() instead."
Our server had no API gateway in front of it, so the anon upload above sent no apikey header check. On hosted Supabase the gateway may also want the publishable key. That key ships in your frontend anyway, so do not count on it as protection.
How long the token lasts
The createSignedUploadUrl reference says signed upload URLs "are valid for 2 hours." The open source server reads the lifetime from UPLOAD_SIGNED_URL_EXPIRATION_TIME in its config and falls back to 60 seconds. We saw exactly that: tokens lived 60 seconds with no setting, 7,200 seconds with it set to 7200, and with it set to 3 a token used 5 seconds later failed with InvalidJWT and "exp" claim timestamp check failed.
In supabase-js 2.118.0, createSignedUploadUrl takes only an upsert option. There is no expiresIn like createSignedUrl has, so on hosted Supabase plan for a window of about 2 hours per token. Resumable uploads have their own limit: the resumable uploads docs say each TUS upload URL "will be valid for up to 24 hours."
Bucket limits still apply
The token does not skip the bucket's own settings. With allowed_mime_types set to image/png and file_size_limit set to 1,000 bytes:
- An HTML file sent with a valid token got
mime type text/html is not supported(415,InvalidMimeType). - A 5,000 byte PNG got
The object exceeded the maximum allowed size(413,EntityTooLarge). - A 100 byte PNG uploaded fine.
So set both on every bucket that takes signed uploads. They are the only checks left once the token exists. In the uploader source the MIME check uses the content type the upload sends, so it stops HTML pages and scripts that announce themselves, not a file lying about its type. Treat file contents as untrusted, as covered in secure file uploads.
List vs download vs signed URL: one SELECT privilege
Reading a file, listing a folder and creating a signed download URL all run a SELECT on storage.objects, so one SELECT policy covers all three. With only an INSERT policy in a private bucket, for files Alice had just uploaded:
| Call | Without a SELECT policy | With the owner folder SELECT policy |
|---|---|---|
list('<alice id>') |
[], no error |
Her files |
download('<alice id>/a.txt') |
Object not found (404) |
The file |
createSignedUrl(...) |
Object not found (404) |
A signed URL |
An empty list() with no error is the one that confuses people: the code looks fine and the folder looks empty. It means no SELECT policy matched, not that the folder is empty.
Two more things we saw. A signed download URL kept working after we dropped the SELECT policy (it returned 200 with the file), while Alice's own download() returned Object not found. And a trigger from Storage's migrations refused a plain SQL delete from storage.objects, even as the postgres user, with "Direct deletion from storage tables is not allowed. Use the Storage API instead." Delete files with remove() or the dashboard, not SQL.
Allow downloads without listing
Supabase added two helpers for this. The helper functions docs say storage.allow_only_operation() "is useful when a single SQL privilege such as SELECT is used by multiple Storage actions, but you want a policy to apply to only one of them, such as object listing versus object download." Operation names come from operations.ts, and the storage. prefix is optional.
-- users can download and sign their own files, but not list folders
create policy "Users read own files, no listing"
on storage.objects for select
to authenticated
using (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = (select auth.uid())::text
and not storage.allow_any_operation(array['object.list', 'object.list_v2'])
);
With that policy, Alice's list() returned [], while download(), createSignedUrl() and plain uploads worked. With storage.allow_only_operation('object.get_authenticated') instead, download() worked and createSignedUrl() returned Object not found, because signing is its own operation (object.sign). The helpers came in Storage migration 0058-operation-ergonomics. If create policy says the function does not exist, your project has not received them yet.
Supabase's access control docs use the same idea for a readable but not listable bucket, and warn: "The allow_any_operation() filter is critical here as without it users would be able to list the bucket contents."
What a public bucket exposes
The bucket docs say a public bucket "effectively bypasses access controls for both retrieving and serving files within the bucket." In our public bucket with only an INSERT policy:
- The public URL
/object/public/pub/<alice id>/avatar.txtreturned 200 with no auth. download()with only the anon key also worked. For public buckets the server reads the object as superuser (getObject.ts).list()with the anon key returned[], at the root and in Alice's folder.createSignedUrl()with the anon key returnedObject not found.
Then we added the policy an AI tool usually writes when list() comes back empty:
-- do not ship this
create policy "public read" on storage.objects
for select to public using (bucket_id = 'pub');
Now anon listed the root and got Alice's user id folder, then listed that folder and got avatar.txt and private-notes.txt. Listing hands a stranger every path, and in a public bucket every path is a download.
Replacing it with the owner folder SELECT policy made anon's and Bob's lists empty again, and Alice saw her own files. But private-notes.txt still downloaded from its public URL for anyone who knew the path. A public bucket is public per file. Anything you would not post on your homepage belongs in a private bucket.
Fix 1: create signed upload URLs on the server, with a path you choose
The safest shape is a server route or Edge Function that checks the user, builds the path itself, and returns a token for a new, random file name. Then no upsert is needed and the token cannot replace anything that exists.
// Edge Function or server route (shape only). Uses the caller's own JWT,
// so your INSERT policy still runs when the token is created.
import { createClient } from '@supabase/supabase-js';
export async function createAvatarUpload(req: Request) {
const supabase = createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
global: { headers: { Authorization: req.headers.get('Authorization') ?? '' } },
});
const { data: { user } } = await supabase.auth.getUser();
if (!user) return new Response('Unauthorized', { status: 401 });
const path = `${user.id}/${crypto.randomUUID()}.png`; // never from the request body
const { data, error } = await supabase.storage
.from('avatars')
.createSignedUploadUrl(path); // no upsert
if (error) return new Response('Upload not allowed', { status: 403 });
return Response.json({ path: data.path, token: data.token });
}
The browser then calls uploadToSignedUrl(path, token, file). If you sign with the service role instead, no policy runs at all, so the path check in your code is the only check, and owner_id on the row will be NULL. That breaks owner_id based policies later, as the upload RLS guide shows.
Fix 2: avoid upsert tokens
An upsert token is a two hour license to replace one file, as many times as wanted, by whoever holds it. Prefer a new path per upload and update the path stored in your table. Supabase's standard uploads docs already advise against overwriting files because of CDN caching. If you must overwrite, sign right before the upload and do not keep the token.
Fix 3: treat the token like a password for one file
- Never log signed upload URLs or tokens, put them in analytics events, or send them to third party scripts.
- Never store them in your database or in local storage for later.
- Never email or share them as a "drop your file here" link unless that is the feature, and then use a fresh random path per recipient.
- After the upload, confirm on the server that the file at that path is what you expected before you use it.
Removing a policy or a user does not cancel a token. If a token leaks, the only fix is to wait out the expiry and check what was written to that path.
Fix 4: write SELECT policies per operation
Give each bucket the SELECT policy it needs and no more: owner folder for private files, allow_any_operation or allow_only_operation when users should download but not list, and no using (bucket_id = '...') policy for public or anon on a bucket with user files.
Fix 5: set bucket limits
Set allowed_mime_types and file_size_limit on every bucket that accepts uploads. In our test they were the last checks a signed upload hit.
Common AI generated mistakes
- Making the server route accept the path from the request body, so any signed in user can request a token for someone else's folder. With the service role, nothing else stops it.
- Signing with the service role "so it works", which skips every policy and leaves
owner_idNULL. - Always passing
upsert: truetocreateSignedUploadUrl, which turns each token into a reusable overwrite token. - Passing
upsert: truetouploadToSignedUrland expecting it to change anything. - Creating the token on page load and keeping it in state or local storage, instead of right before the upload.
- Adding
for select to public using (bucket_id = 'x')becauselist()returned an empty array, which lets anyone list every file. - Reading an empty
list()as "no files", when it means no SELECT policy matched. - Using a public bucket for documents because the public URL is easy, then hiding them behind "nobody knows the path."
- Leaving bucket MIME and size limits unset, so a token can be used to upload HTML or large files.
Checklist
- Signed upload URLs are created on the server, after checking the user, for a path the server builds from the user id
- New uploads use a new random file name, and
upsert: trueis only used where a file must be replaced - Tokens are created right before the upload and never logged, stored or shared
- Signing uses the caller's JWT so the INSERT policy runs, or the service role code has its own path check
- Every bucket that takes uploads has
allowed_mime_typesandfile_size_limitset - SELECT policies check
bucket_idand the owner folder, and useallow_any_operationorallow_only_operationwhere listing should be off - No SELECT policy for
publicoranonthat checks onlybucket_idon a bucket with user files - Private files live in private buckets, served with short signed download URLs
- A test signs in as user B and tries to get a token for user A's folder, list A's folder and download A's file, and expects errors or empty results
The RLS testing guide shows how to run that last check in CI, and the Supabase RLS checklist covers your tables.
Copy-paste prompt for your AI coding tool
Paste this into Cursor, Claude Code, Codex, Lovable, Bolt, or Replit inside your own project. It asks for code and SQL changes only. Do not paste database passwords, connection strings, or keys into the chat.
This project uses Supabase Storage. Do not print, log, or ask me for any secret values. 1) Find every call to createSignedUploadUrl and uploadToSignedUrl. For each one, say where the token is created (browser, server route, Edge Function), which key the client uses (user session or service role), how the path is built, and whether upsert is true. Flag any path that comes from the request body or a form field, any service role signing without its own check that the path starts with the signed in user's id, and any upsert: true that is not needed. 2) Flag any place a signed upload URL or token is logged, stored, put in state that outlives the upload, or sent to analytics. 3) Rewrite the signing code so a server route checks the user, builds the path as the user id plus a random file name, signs without upsert using the caller's JWT, and returns only the path and token. 4) Write a read only SQL query that lists every policy on storage.objects from pg_policies with its command, roles, using and with check, and flag any SELECT policy for public or anon that checks only bucket_id. 5) For buckets where users should download but not list, rewrite the SELECT policy with the owner folder check and not storage.allow_any_operation(array['object.list', 'object.list_v2']). 6) List each bucket and whether allowed_mime_types and file_size_limit are set. 7) Write a test where user B tries to create a signed upload URL for user A's folder, list A's folder and download A's file, and expects an error or an empty result. Show me the migration before running anything.
More prompts are in the security review prompt guide.
Check what your live app exposes with LeakCheck
LeakCheck scans any live app URL or public GitHub repo, read only, wherever it is hosted. On a live app that uses Supabase, it finds the key your app ships and checks whether that key can read your tables, alongside its other checks for exposed files and leaked keys. On a public repo it looks for committed keys, including a service role key.
Its limits, honestly: it never uploads files or asks for signed upload URLs, so it cannot tell you whether your tokens can be reused or how your signing route builds paths. It also cannot see your storage policy definitions. The tests above, run against your own project, are how you check that.
Sources
- Supabase JavaScript reference: createSignedUploadUrl (valid for 2 hours)
- Supabase JavaScript reference: uploadToSignedUrl (upsert has no effect)
- Supabase: Storage access control (service keys, allow_any_operation example)
- Supabase: Storage helper functions (allow_only_operation, allow_any_operation, foldername)
- Supabase: Bucket fundamentals (public vs private)
- Supabase: Resumable uploads (presigned uploads, 24 hour upload URLs)
- Supabase: Standard uploads (overwriting files)
- Supabase Storage server source: object.ts (signUploadObjectUrl, verifyObjectSignature)
- Supabase Storage server source: uploadSignedObject.ts (upload as superuser)
- Supabase Storage server source: config.ts (UPLOAD_SIGNED_URL_EXPIRATION_TIME)
- Supabase Storage server source: operations.ts (operation names)
- Supabase Storage server source: uploader.ts (MIME check from the request content type)
- Supabase Storage server source: getObject.ts (public bucket reads)
- Supabase Storage server source: migration 0058 operation helpers
Questions
Which RLS policy does createSignedUploadUrl check?
It checks your INSERT policy on storage.objects when the URL is created, by running a test insert for the path and rolling it back. With upsert: true it runs an upsert probe instead, which needed INSERT and SELECT in our test, plus UPDATE when the file already existed. No policy is checked again when the file is uploaded with uploadToSignedUrl. The Storage server writes that upload as a superuser.
Can a Supabase signed upload token be used more than once?
Yes, until it expires. In our test a token created without upsert could not overwrite an existing file (409 The resource already exists), but it uploaded again after the file was deleted. A token created with upsert: true overwrote the same file again and again, for anyone who had it, including another signed in user.
How long is a Supabase signed upload URL valid?
Supabase's JavaScript reference says signed upload URLs are valid for 2 hours. The open source Storage server reads the lifetime from UPLOAD_SIGNED_URL_EXPIRATION_TIME and defaults to 60 seconds when it is not set. createSignedUploadUrl in supabase-js 2.118.0 has no expiresIn option. In our test an expired token was rejected with InvalidJWT, and the signing user's own session expiring did not stop the token.
Does list() in Supabase Storage need a SELECT policy?
Yes. Without a matching SELECT policy, list() returned an empty array with no error, in private and public buckets. download() and createSignedUrl on a private bucket returned Object not found. Use storage.allow_only_operation or storage.allow_any_operation in the SELECT policy if you want downloads without listing.
Can anyone list the files in a public Supabase bucket?
Not by default. In our test anon got an empty list from a public bucket with no SELECT policy, while the public URL served each file with no auth. Listing opened up only when we added a SELECT policy for the bucket, which is the usual fix AI tools suggest to make list() work. Then anon saw every user folder and every file name.
Does uploadToSignedUrl respect the upsert option?
No. Supabase's reference says the upsert option has no effect in uploadToSignedUrl and must be set on createSignedUploadUrl. In our test a token created without upsert still got 409 The resource already exists when the upload sent upsert: true.