Guide · Supabase · Updated
Supabase Storage security for vibe-coded apps: public buckets, storage RLS and signed URLs
The upload button is the feature everyone asks for early. A profile photo, a receipt, a contract, a selfie with an ID for verification. You ask your AI builder for it, it makes a bucket, the first upload fails with a policy error, the AI writes a policy that makes the error go away, and it works. That last step is the one to worry about, because “the error went away” and “only the right person can see this file” are very different things. This guide is how to tell which one you have, written for apps on Supabase built with Lovable, Bolt, v0, Replit, Cursor or by hand.
New to this? Start with our vibe coding security checklist, eleven checks for any AI-built app. If your tables aren’t locked down yet, do the Supabase RLS checklist first. This guide is about files.
The honest short answer
Supabase Storage is safe by default. Supabase’s bucket docs say “Buckets are private by default,” and its access control docs say “By default Storage does not allow any uploads to buckets without RLS policies.” Nothing is open until someone opens it. The trouble is that in an AI-built app, something usually did open it, to get past an error. The bucket got switched to public so images would show. A policy with true in it went in so uploads would work. A read policy was written for the whole bucket instead of for each user’s own files.
None of that shows up when you test your own app, because you only ever open your own files. It shows up when somebody else asks for yours. Below are eight checks, in the order we’d do them, mostly a query for the SQL editor and a prompt for your builder.
This happens to real apps
The best-known file leak of the last few years wasn’t on Supabase, and it wasn’t vibe-coded, but it’s exactly the shape of the risk. In July 2025, the dating safety app Tea confirmed that, in its words, “A legacy data storage system was compromised.” Its statement said the dataset included “approximately 13,000 selfies and photo identification submitted by users during account verification.” Engadget reported that the data came from an exposed Firebase storage bucket and that “404 Media verified that the exposed storage bucket URL matches one found in Tea’s Android app.” The BBC noted Tea’s privacy policy had promised those ID photos would be deleted after verification.
Simon Willison, who collected Tea’s statement, pointed out that the code in question was written “long before vibe coding was close to viable for building systems of this nature.” So this isn’t an AI problem. It’s a files problem: a bucket that should have been locked wasn’t, and its address sat in the app. The difference with an AI builder is speed. You can go from idea to ID-photo upload in an afternoon, and the step where someone asks “who can read this bucket?” is easy to skip. On Firebase, our Firebase Security Rules checklist covers Cloud Storage rules. The rest of this guide is Supabase.
How a file gets to someone in Supabase
It helps to know the four doors, because each check below is about one of them.
- The public link. If the bucket is public, a URL like
/storage/v1/object/public/bucket/pathhands the file to anyone, with no key and no login. No policy is consulted. - A request with a key or a login. Your app asks with the public key, plus the user’s session if they’re signed in. Supabase runs your RLS policies on the
storage.objectstable to decide. This is also how listing works. - A signed link. A URL with a token in it that works for anyone who has it, until it expires.
- The secret key. Supabase’s docs say “Service keys entirely bypass RLS policies, granting you unrestricted access to all Storage APIs.” Whoever holds it can do anything.
The job is to make sure each door only opens for the people you meant.
The eight checks
Checks 1 to 3 are the ones we’d bet on, and they’re also the quickest. Run everything against your own project only. The outside test in check 4 sends a handful of requests and writes one tiny test file; don’t point it at anyone else’s app.
- Find out which buckets are public, and what that really means
- Read every storage policy and find the ones that let everyone in
- Give every user one folder, for all four actions
- Test it from the outside, the way a stranger would
- Let a public bucket serve files, not list them
- Use short signed links for private files
- Cap file size and type on every bucket
- Keep the secret key out of the browser, and check before your server signs
01
Find out which buckets are public, and what that really means
“Public” in Supabase Storage means something specific, and it’s more than most people expect. The bucket docs say a public bucket “effectively bypasses access controls for both retrieving and serving files within the bucket,” so anyone who has a file’s URL can download it, with no key and no login. The same page adds that “Access control is still enforced for other types of operations including uploading, deleting, moving, and copying.” So public is about reading, and only about reading.
A long URL isn’t a secret. It sits in your page’s HTML, in browser history, in a link pasted into a chat. Supabase’s getPublicUrl reference also points out that “This function does not verify if the bucket is public.” It just builds the address. So when the AI uses it to show an invoice, the app works only because the bucket is public.
Here is how to see where you stand. Paste this into the SQL editor:
-- Every bucket, public ones first, with its limits and how many files it holds.
select b.id, b.public, b.file_size_limit, b.allowed_mime_types,
(select count(*) from storage.objects o where o.bucket_id = b.id) as files
from storage.buckets b
order by b.public desc, b.id;Read the public column and ask one question per bucket: would I be fine with a stranger downloading any file in here? Avatars and product photos, probably yes. Anything one user uploaded for themselves, like documents, receipts or ID photos, no. Those buckets should say false. If you built with a tool that hides the SQL editor, look here instead:
- Lovable Cloud: More → Cloud → Storage. Lovable’s storage docs say each bucket card shows Private or Public, and that public files “are accessible by anyone with the URL.” Lovable also says “Public storage buckets are blocked by default on all plans,” through a workspace setting described in its privacy and security settings. So a public bucket there is one somebody chose. Find out why.
- Bolt Cloud: the database icon, then File Storage. Bolt’s file storage page labels each bucket and says of public ones: “Anyone with the link can see these files, even if they’re not signed in to your app.” Bolt says it is designed to make private buckets with RLS policies for user uploads, and also tells you to “Always confirm a bucket’s visibility yourself.”
- Your own Supabase project (common with v0, Replit, Cursor and Lovable or Bolt connected to your own account): the Storage page in the Supabase dashboard, or the query above.
To turn a bucket private, it’s one line:
-- A bucket of private files should not be public. Old public links stop working
-- as soon as this runs, so switch the app to signed URLs (check 6) in the same change.
update storage.buckets set public = false where id = 'documents';Two warnings from our testing. Every file your app shows with a public URL stops loading the moment that runs, so plan it together with check 6. And private isn’t enough on its own: in our test, the public link stopped working, but a broad read policy the AI had added was still there, so a stranger could still list every user’s folder and download the file with just the public key. That’s check 2. Anything that was public before, treat as already seen.
List every Supabase Storage bucket this app uses, whether it is public or private, and what kind of files go in it. Any bucket that holds files belonging to one user (documents, invoices, ID photos, private uploads) must be private. For those, stop using getPublicUrl and show files through short signed URLs created with the user's session. Don't change buckets that only hold public things like product photos. Show me the list and the plan before you change anything.02
Read every storage policy and find the ones that let everyone in
For everything except the public link, Supabase decides with Row Level Security policies on one table, storage.objects, which holds a row for every file. These are the same kind of policies as on your own tables, and they go wrong in the same ways. This query lists them all and spells out the usual problems next to each one:
-- Every policy on storage.objects, with the usual problems spelled out.
select policyname, cmd, roles,
coalesce(qual, '') as using_expr,
coalesce(with_check, '') as check_expr,
concat_ws('; ',
case when roles && array['public', 'anon']::name[] then 'applies to people who are not signed in' end,
case when coalesce(qual, 'true') = 'true' and coalesce(with_check, 'true') = 'true' then 'allows every file in every bucket' end,
case when concat(qual, with_check) !~ 'bucket_id' then 'no bucket check' end,
case when concat(qual, with_check) !~ '(auth\.uid|auth\.jwt|owner_id)' then 'no owner check' end
) as problems
from pg_policies
where schemaname = 'storage' and tablename = 'objects'
order by policyname;We loaded Supabase’s own open-source storage schema into a local Postgres, added the kind of policies an AI tends to write, and ran it. Here is what each problem means in practice:
- “allows every file in every bucket”. A policy whose rule is just
true. In our test, an insert policy like that let a stranger with only the public key upload into any bucket. - “applies to people who are not signed in”. The policy is for the
publicoranonrole, or for no role at all, which means public. Fine for reading shared images, almost never for uploads, updates or deletes. - “no owner check”. The policy checks the bucket but not whose file it is. A read policy like
bucket_id = 'documents'lets every signed-in user read every user’s documents. In our test, a delete policy written that way let one user delete another user’s invoice. - “no bucket check”. The rule applies to every bucket, including ones you add later.
One more thing that catches people out. Supabase’s ownership docs explain that every file records who uploaded it in owner_id, but also that “By itself, the ownership of a resource does not provide any access control.” It only protects anything if a policy checks it.
When you find a bad policy, remove it with drop policy "the policy name" on storage.objects; and put a proper one in its place from check 3.
Show me every RLS policy on storage.objects. Remove any policy that uses true, any policy for the public or anon role that allows insert, update or delete, and any select, update or delete policy that only checks bucket_id without checking that the file belongs to the signed-in user. Replace them with policies that keep each user inside a folder named after their user ID, as in the next step. Show me the SQL before you run it.03
Give every user one folder, for all four actions
The pattern that holds up is simple: every user gets a folder named after their user ID, and every rule checks that the file is in the caller’s own folder. Supabase’s helper functions make that readable. storage.foldername(name) turns a path like documents/2b1c…/invoice.pdf into its list of folders, and the first one is the user ID. Here is the full set for a private bucket called documents:
-- Private bucket "documents": each user gets one folder named after their user ID,
-- like documents/<user id>/invoice.pdf. Nobody else can list, read, change or delete it.
create policy "documents: read own folder"
on storage.objects for select to authenticated
using (bucket_id = 'documents' and (storage.foldername(name))[1] = (select auth.uid()::text));
create policy "documents: upload to own folder"
on storage.objects for insert to authenticated
with check (bucket_id = 'documents' and (storage.foldername(name))[1] = (select auth.uid()::text));
create policy "documents: update own files"
on storage.objects for update to authenticated
using (bucket_id = 'documents' and (storage.foldername(name))[1] = (select auth.uid()::text))
with check (bucket_id = 'documents' and (storage.foldername(name))[1] = (select auth.uid()::text));
create policy "documents: delete own files"
on storage.objects for delete to authenticated
using (bucket_id = 'documents' and (storage.foldername(name))[1] = (select auth.uid()::text));Each action needs its own policy, and the update one needs both halves. using checks the file before the change, and with check checks it after. Without the second half, a user could move their file into someone else’s folder. You also need select and update for overwriting: Supabase says “To allow overwriting files using the upsert functionality you will need to additionally grant SELECT and UPDATE permissions.” We write (select auth.uid()::text) rather than plain auth.uid() because Supabase’s RLS guide says wrapping it lets Postgres cache the result per statement “rather than calling the function on each row.”
We ran these with two test users, Ana and Ben, through the real supabase-js client and a stand-in for Supabase’s storage API. Ana could upload, list, download, overwrite and delete in her own folder. In Ben’s, listing came back empty, downloading said “Object not found”, deleting removed nothing, and moving her file in was refused.
Two things this depends on. Your app has to upload to <user id>/file paths, built from the session, never typed or chosen in the browser. If it uploads somewhere else, change that first. If you have anonymous sign-ins on, Supabase’s Security Advisor docs say “Anonymous users use the same authenticated Postgres role as permanent users.” So every to authenticated policy above lets an anonymous visitor in too. The anonymous sign-in docs suggest checking the is_anonymous claim:
-- Only if anonymous sign-ins are on: they get the authenticated role too,
-- so keep them from uploading. Do the same for any other policy they shouldn't pass.
alter policy "documents: upload to own folder" on storage.objects
with check (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid()::text)
and (select (auth.jwt()->>'is_anonymous')::boolean) is false
);In our test, an anonymous user could upload to their own folder before that change and was refused after it, while Ana still could.
Make every upload in this app go to a path that starts with the signed-in user's ID, like documents/<user id>/<file name>, and never let the browser choose that first folder. Then add four RLS policies on storage.objects for each private bucket (select, insert, update with both using and with check, and delete), all for the authenticated role, all checking bucket_id and (storage.foldername(name))[1] = (select auth.uid()::text). If anonymous sign-ins are on, also require (select (auth.jwt()->>'is_anonymous')::boolean) is false where anonymous users shouldn't get in. Show me the SQL first.04
Test it from the outside, the way a stranger would
Reading policies tells you what you meant. This tells you what a stranger actually gets, using the public key your app already sends to every visitor. Run it on your own computer with your project URL, key, bucket names and the path of one real private file:
# Your project URL and the public key your app already ships.
SUPABASE_URL=https://YOUR-PROJECT-REF.supabase.co
KEY=sb_publishable_YOUR_KEY # or the older anon key, which starts with eyJ
H=(-H "apikey: $KEY"); case $KEY in eyJ*) H+=(-H "Authorization: Bearer $KEY");; esac
# 1. Which buckets can a stranger see? [] is the usual answer, and fine.
curl -s "${H[@]}" "$SUPABASE_URL/storage/v1/bucket"; echo
# 2. Can a stranger list what's in each bucket? Use your own bucket names. [] is good.
for b in avatars documents uploads; do
echo "$b: $(curl -s "${H[@]}" -H 'content-type: application/json' \
-d '{"prefix":"","limit":5}' "$SUPABASE_URL/storage/v1/object/list/$b")"
done
# 3. One of your private files (copy its path from the Storage page), asked for
# with no user signed in. Anything but 200 is good.
FILE=documents/SOME-USER-ID/invoice.pdf
curl -s -o /dev/null -w "public link: %{http_code}\n" "$SUPABASE_URL/storage/v1/object/public/$FILE"
curl -s -o /dev/null -w "public key only: %{http_code}\n" "${H[@]}" "$SUPABASE_URL/storage/v1/object/authenticated/$FILE"
# 4. Can a stranger upload? This tries to write a 5-byte file to your own bucket.
# "new row violates row-level security policy" is good.
curl -s "${H[@]}" -H 'content-type: text/plain' --data-binary 'probe' \
"$SUPABASE_URL/storage/v1/object/uploads/stranger-test.txt"; echoWe send the key on the apikey header only, because Supabase’s API keys docs say “Send publishable and secret keys on the apikey header, not on Authorization: Bearer.” If you still use the older anon key, the script adds the second header for you. Here is how to read what comes back:
- The bucket list will usually be
[]even if you have public buckets. That isn’t a sign that you’re safe. The supabase-js source notes that listing buckets needs a select permission on the buckets table, and by default the public key has none. In our test it said[]with two public buckets. Check 1 is the real answer for which buckets are public. - The file lists should all be
[]. If you see names, a stranger can list that bucket. - The private file should not come back with a
200on either line. The first line is the public link, the second a request with only the public key. - The upload should fail with “new row violates row-level security policy”. If it comes back with a Key, strangers can upload, and you now have a test file to delete from the dashboard. If it says the mime type isn’t supported, the type limit stopped it before the policy ran, so use a type that bucket allows.
Before our fixes, the script showed both users’ folders, served the invoice both ways and accepted a stranger’s upload. After checks 1 to 3 and 5, every list was empty, both file requests came back 404 and the upload was refused.
05
Let a public bucket serve files, not list them
This one looks so reasonable that AI builders get it wrong a lot. You have a public bucket for avatars. The AI also adds a policy like “Avatar images are publicly accessible” that allows select on the whole bucket. It seems harmless, because the bucket is public anyway. It isn’t. Supabase’s Security Advisor has a warning for exactly this, lint 0025, and the rationale is plain: “Supabase public buckets are already readable by URL.” They don’t need a select policy. Adding a broad one means “Clients can enumerate the files in a public bucket, which often exposes more information than intended even though public object URLs would still work without the policy.”
Loading a file you know about and listing every file are different powers. A list hands a stranger every path, and every user ID with it. Supabase’s access control page makes the same point about its own example policy: “The allow_any_operation() filter is critical here as without it users would be able to list the bucket contents.”
-- A public bucket needs no select policy for its files to load by URL.
-- A broad one only lets anyone list everything in it, so drop it
-- (use the name the query in check 2 showed you):
drop policy if exists "Avatar images are publicly accessible" on storage.objects;
-- Uploads that replace a file (upsert) still need a select policy,
-- so give people one that covers only their own folder:
create policy "avatars: see own folder"
on storage.objects for select to authenticated
using (bucket_id = 'avatars' and (storage.foldername(name))[1] = (select auth.uid()::text));For uploads to that bucket, copy the insert, update and delete policies from check 3 and change documents to avatars. In our test, a stranger’s listing of avatars then came back empty, Ben couldn’t replace Ana’s avatar, Ana could, and the public URL still loaded for everyone.
One habit that helps here too: name uploaded files with a random ID rather than the original file name. “passport-scan-maria.jpg” tells a stranger a lot even if they can only see the name.
For each public storage bucket, remove any select policy on storage.objects that lets everyone read the bucket, because public files already load by URL without one. Keep or add only a select policy for the authenticated role limited to the user's own folder, plus insert, update and delete policies limited to the user's own folder. Name new uploads with a random ID instead of the original file name. Don't make any private bucket public.06
Use short signed links for private files
A private bucket needs another way to show people their own files: download with the user’s login, or a signed URL. The createSignedUrl reference sums the second one up: “Use a signed URL to share a file for a fixed amount of time.” Two parts of that matter for security.
“Share” means it works for whoever has the link; ours opened with no key and no login. And “a fixed amount of time” means you can’t easily take it back. Supabase’s serving docs say signed URLs are signed with a separate key from your auth keys, and that “Signed URLs remain valid until their expiry time regardless of any Auth key changes.” If you need to revoke them early, the docs say to contact Supabase support. So keep them short, make a fresh one per click, and never store them. Lovable’s Copy URL button, for comparison, gives you one that the Lovable docs say “expires after one hour,” which is fine for you sharing a file by hand and too long for your app to hand out.
Here are both ways to make one, from tested code:
import type { SupabaseClient } from "@supabase/supabase-js";
// In the app, with the signed-in user's session. Storage runs your select
// policy on this path first, so nobody can sign someone else's file.
export async function documentLink(supabase: SupabaseClient, path: string) {
const { data, error } = await supabase.storage
.from("documents")
.createSignedUrl(path, 60); // seconds: enough to open it, not to pass it around
if (error) throw error;
return data.signedUrl;
}
// On your server, with the secret key. Policies don't apply to that key,
// so check that the file is in this user's folder before you sign it.
export async function documentLinkForUser(admin: SupabaseClient, userId: string, path: string) {
const parts = path.split("/");
if (parts.length !== 2 || parts[0] !== userId || !parts[1] || parts[1].startsWith(".")) {
throw new Error("Not your file");
}
const { data, error } = await admin.storage.from("documents").createSignedUrl(path, 60);
if (error) throw error;
return data.signedUrl;
}The first runs in your app with the user’s own session, and Supabase checks your select policy before signing. In our test, Ben asking for a link to Ana’s invoice got “Object not found”. A 1-second link stopped working after it expired, with “jwt expired”, and a link with the path edited to point at another file was refused.
The second runs on a server with the secret key, and there the policies don’t apply. In our test, it happily signed Ben’s passport for anyone. So the function checks that the path is exactly the user’s folder and a file name, and it refused paths with someone else’s ID, with .., with extra folders and with no file name. If your app signs files on the server, that check, or one like it, is the whole security of the feature.
There’s a second kind of signed URL, for uploads. The createSignedUploadUrl reference says they “can be used to upload files to the bucket without further authentication” and “They are valid for 2 hours.” Create one only after checking who’s asking, and only for a path in their folder.
Show private files with signed URLs that last at most 60 seconds (use longer only for video, and tell me why). Create them in the browser with the signed-in user's own Supabase session, so the storage policies decide who may sign what. If any server code creates signed URLs with the secret or service_role key, make it first check that the path is inside the signed-in user's own folder and refuse anything else, including paths with "..". Never store a signed URL in the database or send it by email.07
Cap file size and type on every bucket
This is the check that protects your bill as much as your users. Supabase’s storage pricing page starts with “You are charged for the total size of all assets in your buckets,” and downloads count toward egress. A loose upload policy with no size limit is an invitation to use your project as free file hosting. On a paid plan, check your Spend Cap setting too.
Supabase lets you set a global file size limit in Storage settings, and on the Free plan “the limit can’t exceed 50 MB.” More useful is a limit per bucket, plus a list of allowed types, which the creating buckets page describes. If an upload doesn’t meet them, it is rejected. You can set both with SQL:
-- Avatars: PNG, JPEG or WebP only, at most 2 MB each. file_size_limit is in bytes.
update storage.buckets
set file_size_limit = 2 * 1024 * 1024,
allowed_mime_types = array['image/png', 'image/jpeg', 'image/webp']
where id = 'avatars';
-- Documents: PDFs only, at most 10 MB each.
update storage.buckets
set file_size_limit = 10 * 1024 * 1024,
allowed_mime_types = array['application/pdf']
where id = 'documents';In our test, a 3 MB avatar was refused with “The object exceeded the maximum allowed size”, an HTML file and an SVG were refused as unsupported types, and a 1 MB PDF went into documents fine. On Lovable Cloud, the storage docs say “Your app’s storage accepts files up to 2 GB by default,” adjustable from the Storage view, which is a lot more than an avatar field needs, so set per-bucket limits as well.
Be clear about what the type check is. Supabase’s storage server, which is open source, compares the type the upload says it is, the Content-Type header, with your list. It doesn’t look inside the file. We confirmed it: a file full of HTML, sent with the label image/png, was accepted into the avatars bucket. So the list stops honest mistakes and casual abuse, but treat every upload as untrusted anyway. We left text/html and image/svg+xml out because both can carry script.
And if you ever clean up files, use the Storage API or the dashboard, not SQL. Supabase’s schema docs warn: “Deleting the metadata doesn’t remove the object in the underlying storage provider. This results in your object being inaccessible, but you’ll still be billed for it.” If you replace public files often, read the CDN docs too, because public files are cached.
Set a file size limit and a list of allowed MIME types on every Supabase Storage bucket, matching what each upload field really needs (for example images under 2 MB for avatars, PDFs under 10 MB for documents). Don't allow text/html or image/svg+xml unless a feature needs them. Check the file size and type in the upload form too, so people get a clear message, but keep the bucket limits as the real check. Show me the SQL.08
Keep the secret key out of the browser, and check before your server signs
Every check above assumes the browser only has the public key. If the secret key (sb_secret_) or the older service_role key is in your JavaScript, none of it matters, because that key skips every policy. It happens for an understandable reason: an upload fails, and the AI swaps in the key that ignores the rules. Supabase’s API keys docs are blunt: “Never put one in a browser, a shipped application, or source control.” They also note that “A secret key doesn’t work in a browser” for the new keys, which helps, but the older service_role key has no such block, and anyone who copies a key can use it from other tools. To see what your live site ships, run this on your own computer:
APP=https://your-app.com
# 1. The scripts your homepage loads
curl -s "$APP/" | grep -oE '(src|href)="/[^"]+\.js"' | cut -d'"' -f2 | sort -u \
| while read -r f; do curl -s "$APP$f"; done > app.js
# 2. New-style secret keys (nothing printed is good)
grep -ohE 'sb_secret_[A-Za-z0-9_-]{10,}' app.js | cut -c1-16 | sort -u
# 3. Old-style keys are JWTs. Print the role inside each: anon is fine, service_role is not.
grep -ohE 'eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}' app.js | sort -u \
| while read -r t; do
p=$(echo "$t" | cut -d. -f2 | tr '_-' '/+'); while [ $(( ${#p} % 4 )) -ne 0 ]; do p="$p="; done
echo "$p" | base64 -d 2>/dev/null | grep -oE '"role":"[^"]+"'
doneWe tried it on a test page with a planted secret key and a planted service_role key. It printed the start of the secret key (not the whole thing, so it doesn’t end up in your terminal history), and the role inside each JWT: anon, which is fine, and service_role, which isn’t. If you see a secret key or service_role, rotate it in the Supabase dashboard first, then move it to the server. This only reads the scripts your homepage loads. Our guide to leaked API keys in your frontend bundle has a longer version that follows every file your app loads later, and the order to rotate keys in.
The secret key on the server is fine, as long as the server does the checking the policies would have done. That’s the second function in check 6. A function that signs or deletes whatever path it’s given, without checking who’s asking, is the same hole with an extra step.
Search the whole project for the Supabase secret key (sb_secret_) or the service_role key, including environment variables with NEXT_PUBLIC_ or VITE_ in the name. Browser code must only use the publishable or anon key. Move any secret key use into server code or an Edge Function, and before that code reads, writes or signs any storage file, make it check who the signed-in user is and that the file is in their folder. Tell me which keys I now need to rotate.What a clean result means
If you got through all eight, you’re in better shape than most apps with an upload button. Private files are in private buckets, each user is boxed into their own folder for every action, nobody can list a public bucket, links expire quickly, uploads have limits, and the secret key is where it belongs. That’s real work.
It won’t stay that way on its own. The next feature the AI adds, like a team folder or a shared album, is exactly where a broad policy comes back. Run the query from check 2 and the script from check 4 again after any change that touches uploads. Lovable’s security docs say its Quick scan’s “database review also covers related database settings, such as file storage rules and leaked-password protection,” and Supabase’s Security Advisor catches public buckets that can be listed. Use them. They see the inside of your project, which an outside scan can’t.
Where LockShip fits
We built LockShip to do the outside-in part of lists like this for you. You paste your app’s address, prove you own it, and the scan reads your app the way a stranger would. It finds the Supabase address and public key in your page, checks which tables that key can read, and searches your JavaScript and any public source maps for secret keys, including a Supabase secret key or service_role key. That covers the first half of check 8.
For storage, it does very little, and we want to be plain about that. It asks Supabase for the bucket list with your public key, one request per project, and if the list comes back it flags every public bucket in it. But as check 4 explains, Supabase hides the bucket list from the public key by default. We ran our scanner against this guide’s test project, with a public bucket full of private documents, and it reported nothing about storage. It only flagged the bucket once we let the public key see the list. So if your report says nothing about storage, read that as “not tested”, not “fine”.
The scan only sends GET and HEAD requests, and listing files in a bucket is a POST, so it never lists your files. It never downloads a file, never uploads one, never asks for a signed URL, and never signs in, so it can’t see your storage policies, your per-user folders, your size and type limits, or what a second user could do. Checks 1 to 7 are yours to do, with the SQL and the script above. A clean scan means “nothing obvious from the outside right now,” not “your files are safe.”
The summary is free: a score and the single worst thing we found. The $39 Launch Safe certificate includes every finding with a fix prompt you can paste into your AI builder, a PDF, and seven days of re-scans while you fix things. A passing re-scan gives you a badge you can put on your site.
Want to know what a stranger can see in your app today?
Paste your app’s address, prove you own it, and see what your public pages and public key give away. The summary is free.
Run a free scanBefore you rely on a result, read the disclaimer.
Questions people ask
Is a public Supabase Storage bucket safe?
For files you’d happily put on a billboard, yes. Supabase says a public bucket bypasses access control for retrieving and serving files, so anyone who has a file’s URL can download it, signed in or not. Uploads, deletes and moves are still checked by your policies. So avatars, logos and product photos are fine in a public bucket. Invoices, contracts, ID photos and anything one user uploads for themselves belong in a private bucket with per-user policies.
Can people list the files in my Supabase bucket?
Only if a select policy on storage.objects lets them. A public bucket doesn’t need one to serve files, but AI builders often add a broad one anyway, like bucket_id = 'avatars' for everyone, and that lets anyone with your public key list every file name. Supabase’s Security Advisor warns about exactly this (lint 0025, public bucket allows listing). The query and the outside test in this guide show you whether yours can be listed.
How long should a Supabase signed URL last?
As short as the job allows. Anyone who has a signed URL can open the file until it expires, and Supabase says signed URLs stay valid until their expiry time even if you rotate your auth keys. For opening or downloading a file right away, 60 seconds is plenty. Create a new one each time someone clicks, rather than storing links. Signed upload URLs are a separate thing, and Supabase says those are valid for 2 hours.
Will making a bucket private break my images?
Yes, any image shown with a public URL stops loading the moment the bucket turns private, because the public link route only works for public buckets. That is why the fix is one change in two parts: switch the bucket to private and switch the app to signed URLs (or downloads with the user’s session) at the same time. Keep truly public things, like a logo, in a separate public bucket.
Can LockShip check my Supabase Storage?
Only a little, and we’d rather you know that. The scan asks Supabase for the bucket list with your public key. If that list is visible, it flags every public bucket. But Supabase hides the bucket list from the public key by default, so most scans see an empty list and report nothing about storage. The scan never lists files, downloads, uploads, signs in or reads your policies. It does look for Supabase secret keys in your JavaScript. Checks 1 to 7 here are yours to do.
Keep reading
- Vibe coding security checklist: what to check before you launch an AI-built app
- A Supabase RLS checklist for vibe-coded apps
- CVE-2025-48757 explained: what happened to Lovable apps, and how to check yours
- Leaked API keys in your frontend bundle: how to find them and what to do
- Is my Lovable app secure? Seven checks you can do yourself
- Is my Bolt app secure? A plain check before you launch
- A Firebase Security Rules checklist for vibe-coded apps
Sources
We read each of these on 19 October 2026. Supabase’s docs change, so if something here disagrees with them, trust Supabase and tell us at hi@lockship.dev. We tested the SQL in a local Postgres (PGlite) with Supabase’s open-source storage schema loaded, and the script and code against a small stand-in for the Storage API that follows how Supabase’s storage server handles each request, using the real supabase-js client. We didn’t test against Supabase’s hosted service, and we didn’t run any of it against anyone else’s app.
- Storage buckets, Supabase docs
- Storage access control, Supabase docs
- Storage helper functions, Supabase docs
- Storage ownership, Supabase docs
- Serving assets from Storage, Supabase docs
- createSignedUrl, Supabase JavaScript reference
- createSignedUploadUrl, Supabase JavaScript reference
- getPublicUrl, Supabase JavaScript reference
- Creating buckets (restricting uploads), Supabase docs
- Storage file limits, Supabase docs
- The storage schema, Supabase docs
- Storage CDN, Supabase docs
- Performance and Security Advisors (lints 0012 and 0025), Supabase docs
- Anonymous sign-ins, Supabase docs
- Row Level Security, Supabase docs
- API keys, Supabase docs
- Manage Storage size usage, Supabase docs
- Manage Egress usage, Supabase docs
- Cost control (Spend Cap), Supabase docs
- StorageBucketApi.ts (listBuckets permissions), supabase-js on GitHub
- uploader.ts (size and type checks), Supabase Storage server on GitHub
- Storage schema migrations, Supabase Storage server on GitHub
- Storage, Lovable docs
- Privacy and security settings (block public storage buckets), Lovable docs
- Security, Lovable docs
- Database file storage settings, Bolt Help Center
- Official statement from Tea on their data leak, Simon Willison (26 July 2025)
- Tea app suffers breach, exposing thousands of user images, Engadget
- Tea app hacked: images stolen from women’s dating safety app, BBC News