Guide · Supabase · Updated
Supabase anon key exposed? What a stranger can see and call with it
Sooner or later you open DevTools on your own app, see your Supabase key sitting right there in the JavaScript, and your stomach drops a little. Is that a leak? Mostly, no. The key is meant to be public. But “meant to be public” isn’t the same as “harmless”, and what a stranger can do with that key changed quite a bit in 2026. This guide is the plain version: what someone holding your public key can see, what they can call, and how to shrink both. It’s for any app on Supabase, whether you built it with Lovable, Bolt, v0, Replit or Cursor, or wrote it yourself.
New to this? Start with our vibe coding security checklist, eleven checks for any AI-built app. If you haven’t checked your tables’ row level security yet, the Supabase RLS checklist comes first. That’s the wall. This guide is about the map, and the doors around it.
The honest short answer
Your app ships one of two public keys: a publishable key starting with sb_publishable_, or on older projects a long eyJ string called the anon key. Supabase’s guide to securing your data says the publishable key “is safe to expose with RLS enabled,” and that the anon key “can identify your project, but it is not a secret and must be paired with RLS and least-privilege grants.” Its API keys guide is just as clear about the other kind, the secret or service_role key: it bypasses RLS, “so it must never leave your control.”
So the key isn’t the problem. What it can reach is. With your public key, a stranger is treated exactly like a signed-out visitor to your app. They can ask for rows from any table the anon role has a grant on, and your RLS policies decide which rows come back. They can call any database function anon is allowed to run, and RLS doesn’t apply inside functions. They can call your Edge Functions. And until this spring, they could also download a list of every table and column you’d exposed, with one request.
That list isn’t a leak on its own. It holds no rows. But it’s a map, and a map tells someone where to push.
Why the map matters, even though it isn’t the wall
In January 2026, researchers at Wiz looked at Moltbook, a social network for AI agents whose founder said publicly he’d vibe-coded it. Their write-up says they found a Supabase publishable key in the site’s JavaScript, and that without RLS it gave “unauthenticated access to the entire production database - including read and write operations on all tables.” The part worth noticing here is how they found the tables they hadn’t seen yet: “Querying non-existent table names returned hints revealing the actual schema,” and “using this technique combined with GraphQL introspection, we mapped the complete database schema.” And after the first fix, a GraphQL query turned up a table they hadn’t seen, with 29,631 more email addresses from early-access signups.
The missing RLS was the real failure. The map just made it quick to find everything. That’s the balance to keep in your head for the rest of this guide: hide what you can, but spend most of your effort on the wall. A table called admin_notes, a column called is_admin, a function called all_invoice_total are all invitations. If the wall holds, they lead nowhere.
We built a small copy of this setup to check every claim and every snippet below: PostgREST 16.5, the open-source server behind Supabase’s Data API, on a local Postgres with Supabase’s three API roles and its old default grants, plus a fake app with invoices, profiles and an admin notes table. In it, the public key’s map listed the invoices table with all four of its columns, yet asking for invoices as a stranger returned [] because RLS was on. A database function written as security definer, though, handed that same stranger the total of everyone’s invoices. Map, wall, and a door in the wall. We’ll go through all three.
What changed in 2026
Supabase has been closing these doors one by one this year, which is good news, but it means half the advice online is out of date.
- The table list. The Data API used to hand the full OpenAPI description of your exposed schema to anyone with the anon key, at
/rest/v1/. Supabase’s breaking-change notice describes it as “schema details (tables, columns, and types of an exposed schema),” and says “less than 0.1% of our projects” had called it with the anon key in the 24 hours before. New projects stopped answering on 11 March 2026 and existing ones were scheduled to stop on 8 April 2026, with a note that the dates could move later but not earlier. The secret key still gets it. Self-hosted Supabase got the same rule in a gateway change merged in July 2026, so older self-hosted setups may still answer. - GraphQL. Supabase stopped turning on
pg_graphqlfor new projects in early 2026 and, per its notice, also disabled it on existing projects older than 30 days that had zero GraphQL requests. Then pg_graphql 1.6.0 turned introspection off by default for projects created from 29 June 2026. The introspection notice says existing projects keep their old behaviour until pg_graphql is upgraded. - New tables. On 30 October 2026, new tables in existing projects stop being reachable through the Data API until you grant access. More on that in check 7.
- Keys. Supabase’s API keys guide says it is deprecating the old anon and
service_rolekeys by the end of 2026, in favour of publishable and secret keys.
None of that touches the wall. Your rows are still guarded by grants and RLS, exactly as before. And none of it hides what your own page gives away: every request your app makes names the table it reads and the columns it selects, right there in the Network panel.
The nine checks
Checks 1 and 2 show you the map from the outside. Checks 3 to 7 shrink it from the inside. Check 8 is about the keys, and check 9, the one we’d do first if your app has Edge Functions, is about the doors that run your code. You need your project URL and the public key: open your live app, go to the Network panel in DevTools, click any request to supabase.co, and copy the apikey header. Only run these against your own project.
- Ask for your table list the way a stranger would
- Try the two side doors: GraphQL and guessing
- List what the public key can reach, from the inside
- Take away the tables strangers don’t need
- Lock down your database functions
- Move internal tables out of the API
- Make new tables private by default before 30 October
- Use the new keys, and keep the secret one on the server
- Make your Edge Functions ask who is calling
01
Ask for your table list the way a stranger would
Ask the API root for its description, with nothing but the public key:
curl -i "https://YOUR-PROJECT.supabase.co/rest/v1/" \
-H "apikey: YOUR_PUBLIC_KEY"The answer you want is a refusal, a 401 or 403 with a message saying a secret key is needed. Supabase’s notice shows Access to schema is forbidden. One engineer who measured a fresh project on 10 October 2026 got a 401 with Secret API key required for a publishable key and Invalid API key for a legacy anon key instead. Any of those is fine.
The answer you don’t want is a 200 and a long JSON document starting with "swagger":"2.0". Search it for "paths": each entry is a table, a view or a function, and further down, under "definitions", are the columns and their types. That’s PostgREST’s OpenAPI output, and by default it only lists what the calling role has privileges on. In our lab, a table dropped off the list as soon as we revoked the public roles’ access to it, which is what checks 3 to 6 do.
If you get the 200 on hosted Supabase, write to Supabase support with your project ref, because by their timeline that shouldn’t happen any more. If you host Supabase yourself, update your gateway config to the version in that July change. If you run PostgREST on its own, its configuration has openapi-mode = "disabled", which turns the root into a 404.
02
Try the two side doors: GraphQL and guessing
The table list has two other ways out. The first is GraphQL. This is the query from Supabase’s own Security Advisor docs (lint 0026), with your public key in both headers:
curl -X POST "https://YOUR-PROJECT.supabase.co/graphql/v1" \
-H "apikey: YOUR_PUBLIC_KEY" \
-H "Authorization: Bearer YOUR_PUBLIC_KEY" \
-H "Content-Type: application/json" \
--data-raw '{"query": "{ __schema { types { name fields { name } } } }"}'Read the body, not the status: GraphQL tends to answer 200 even when it refuses. Good answers are an error saying the pg_graphql extension is not enabled, or Unknown field "__schema" on type Query, which means introspection is off. A bad answer is a list of types with your table names in it, like invoicesCollection. Supabase’s GraphQL security page says what shows up there is decided by Postgres grants, so checks 3 to 5 shrink it too. And if nothing in your app uses GraphQL (the usual supabase.from() calls go to the REST API), Supabase’s advice for that lint is to drop the extension:
-- Only if nothing in your app uses GraphQL (Supabase's own fix for lint 0026)
drop extension pg_graphql;The second way out is guessing. Take one of your real table names, cut off the last letter, and ask for it:
# One of your real table names with the last letter cut off, e.g. invoice for invoices
curl "https://YOUR-PROJECT.supabase.co/rest/v1/invoice" \
-H "apikey: YOUR_PUBLIC_KEY"In our lab, PostgREST answered like this. PostgREST’s error docs show the same kind of suggestion for its PGRST205 error:
{"code":"PGRST205","details":null,
"hint":"Perhaps you meant the table 'public.invoices'",
"message":"Could not find the table 'public.invoice' in the schema cache"}That’s the trick Wiz used on Moltbook. Here’s the part that surprised us: in our lab the hint kept naming a table even after we’d revoked every privilege on it. Revoking took the table off the map and locked it, but the error message still knew it was there. Only moving it to a schema the API doesn’t expose (check 6) made the hint go quiet. So treat table names as something a patient stranger can learn, and make sure the wall doesn’t care.
03
List what the public key can reach, from the inside
Now look from the inside, where nothing is hidden from you. Paste this into the SQL Editor in your Supabase dashboard. If your builder runs the database for you and there’s no SQL editor in sight, paste the query into its chat and ask it to run it and show you the raw result.
-- What the public key (the anon role) can reach in the schemas your Data API exposes.
-- Run it in the Supabase SQL Editor. Change 'anon' to 'authenticated' to see what any signed-up user can reach.
select c.relname as name,
case c.relkind when 'r' then 'table' when 'p' then 'table' when 'v' then 'view'
when 'm' then 'materialized view' when 'f' then 'foreign table' end as kind,
has_table_privilege('anon', c.oid, 'select') as can_read,
has_table_privilege('anon', c.oid, 'insert') or has_table_privilege('anon', c.oid, 'update')
or has_table_privilege('anon', c.oid, 'delete') as can_write,
c.relrowsecurity as rls_on
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind in ('r', 'p', 'v', 'm', 'f')
and (has_table_privilege('anon', c.oid, 'select, insert, update, delete')
or has_any_column_privilege('anon', c.oid, 'select, insert, update'))
order by c.relname;It uses Postgres’s own privilege functions, so it shows what the database will actually allow, not what anyone meant. Every row is a table or view the public key can reach. can_write true means the grant is there for writing too, and only your RLS policies stand in the way. rls_on false next to anything private means stop here and go to the RLS checklist first.
Then the functions:
-- Database functions the public key can call at /rest/v1/rpc/<name>.
-- security_definer = true means the function runs as its owner and skips your RLS policies.
select p.proname as function,
pg_get_function_identity_arguments(p.oid) as arguments,
p.prosecdef as security_definer
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and p.prokind = 'f'
and has_function_privilege('anon', p.oid, 'execute')
order by p.proname;Change 'anon' to 'authenticated' in both queries and run them again. That’s what anyone who signs up can reach, and on most projects it’s the same list, because both roles get the same default grants. Supabase’s Security Advisor makes the same point about GraphQL: revoking from anon alone “often leaves the same introspection response served to any signed-up user.” If your app lets anyone sign up, a signed-in stranger is only an email address away.
In our lab both queries listed exactly what the API root showed the public key, before and after every fix below.
04
Take away the tables strangers don’t need
Go down the list from check 3 and give each table one of three answers: signed-out visitors need it, only signed-in users need it, or only your server does. Supabase’s API security guide describes the two layers: grants decide “which Postgres roles can reach a table,” and RLS decides “which rows those roles can read or modify.” Most AI-built apps only ever touch the second layer. This check is the first one.
-- Only your server should touch this table: take it away from both API roles.
revoke all on table public.admin_notes from anon, authenticated;
-- Only signed-in users need this table: keep it away from the public key alone.
revoke all on table public.invoices from anon;
-- Signed-out visitors may read this table, but never write to it.
revoke insert, update, delete, truncate on table public.products from anon;In our lab, after the first statement admin_notes was gone from the map for strangers and signed-in users alike, and reading it as either one failed with 42501 permission denied for table admin_notes. After the second, a signed-in user still saw her own invoices and the public key got a permission error. After the third, signed-out visitors could still read the published products and nothing else, and couldn’t add one.
The catch: if a page still reads one of those tables with the public key, it breaks. Search your code for the table name before you revoke anything, and test the app signed out and signed in afterwards. A broken read shows up as a 42501 error, and Supabase’s API security guide says its hint “names the exact GRANT statement you need,” so it won’t be a mystery. Its troubleshooting page for 42501 errors covers the rest. Or hand the whole job to your builder:
Go through every table, view and database function in the public schema of my Supabase project. For each one, tell me who needs it: signed-out visitors, signed-in users, or only the server. Then write one migration that:
- revokes all privileges from anon and authenticated on anything only the server uses,
- revokes all privileges from anon on anything only signed-in users need,
- revokes insert, update, delete and truncate from anon on tables visitors may read but never change,
- revokes execute from public, anon and authenticated on functions only the server calls,
- keeps row level security on for every table.
Before you change anything, search the code for every supabase.from() and supabase.rpc() call, and don't revoke something the browser still reads with the public key. Show me the list first.Step 3 of our RLS checklist has the same idea from the rows’ side.
05
Lock down your database functions
Database functions are the door in the wall. Anything your app calls with supabase.rpc() lives at /rest/v1/rpc/name, and Supabase’s database functions docs say plainly: “By default, any role can run a database function.” Its API security guide adds that “RLS doesn’t apply to functions.” A function marked security definer runs as whoever created it, and the docs warn that it “can return rows the caller isn’t allowed to read.”
That’s the one that bit us in the lab. all_invoice_total was a two-line security definer function, and the public key got back the sum of every user’s invoices, 13,800 cents, from a table that returned nothing when asked directly. Look at the security_definer column from check 3, and for each function only your server should call:
-- Only your server should call this function. Revoke it from public too:
-- Postgres lets every role run a new function unless you say otherwise.
revoke execute on function public.all_invoice_total() from public, anon, authenticated;
grant execute on function public.all_invoice_total() to service_role;After that, the public key and signed-in users both got permission denied for function all_invoice_total, and the server could still call it. If signed-in users do need a security definer function, grant it to authenticated only, and make the function check inside its own body that the rows belong to the caller. Supabase’s docs say an execute grant “doesn’t limit the rows a permitted role gets back.”
06
Move internal tables out of the API
Some tables have no business in the API at all: rate limits, logs, internal notes, anything only your database or a server with a direct connection touches. Supabase’s API security guide suggests keeping the API in its own schema so that “internal tables and helper functions remain in schemas that aren’t exposed,” and its own rate-limit example keeps that table in a schema called private for exactly that reason.
-- A schema the Data API doesn't expose. Don't add it to "Exposed schemas" later.
create schema if not exists private;
alter table public.admin_notes set schema private;This is the only fix in our lab that also silenced the guessing hint from check 2. The table vanished from everyone’s map and no error message mentioned it again. The trade-off is total: the Data API can’t reach it with any key, the secret one included, because only the schemas listed under Exposed schemas in your Data API settings are served (see Supabase’s custom schemas page). So only move tables your app never reads through supabase.from().
07
Make new tables private by default before 30 October
For years, every table you created in public was granted to anon and authenticated the moment it existed. That’s how a table added in a hurry ends up on the map before anyone writes a policy for it. Supabase is changing that. Its announcement says the new behaviour became the default for new projects on 30 May 2026, and that on 30 October 2026 it will be applied to all existing projects. After that, a new table “is not reachable via Data API” until you grant it. Existing tables “keep their current grants and stay reachable.”
You don’t have to wait. Postgres calls these rules default privileges, and these are the statements from Supabase’s API security guide that turn the automatic grants off:
-- From Supabase's "Revoke default privileges" steps: new tables, functions and sequences in public
-- stay off the Data API until you grant them.
alter default privileges for role postgres in schema public
revoke select, insert, update, delete on tables from anon, authenticated, service_role;
alter default privileges for role postgres in schema public
revoke execute on functions from anon, authenticated, service_role;
alter default privileges for role postgres in schema public
revoke usage, select on sequences from anon, authenticated, service_role;
alter default privileges for role postgres in schema public
revoke execute on functions from public;In our lab, a table created after this was refused to the public key and to signed-in users for reading and writing, which is the point. One detail if you self-host: the block leaves truncate, references and trigger in place, and that was enough for PostgREST to keep listing the new table on the public key’s map until we ran revoke all on it. On hosted Supabase that map is closed to the public key anyway (check 1).
The thing to watch after 30 October is your AI builder. The first time it creates a table and hits permission denied, it will want to make the error go away with a grant. Make sure it grants only the role that needs the table, and adds RLS in the same change. Supabase’s own wording: “Treat these three steps as a unit.” Grant, enable RLS, add the policy.
08
Use the new keys, and keep the secret one on the server
Everything above assumes the key in your page is the public one. If it’s a secret key (sb_secret_) or a service_role key, none of it matters: per the API keys guide those keys bypass RLS, and the secret key is exactly the one Supabase still hands the full table list to. Supabase does refuse secret keys that come from a browser, by checking the User-Agent, but its guide warns “an attacker can still use the key from other tools.”
If your project still uses the eyJ keys, plan the move to publishable and secret keys before the end of 2026. Our leaked keys guide shows how to check which key your page really ships and how to rotate one that got out.
09
Make your Edge Functions ask who is calling
Edge Functions are where vibe-coded apps keep the expensive and the private stuff: sending email, charging cards, calling an AI model with your API key. Their addresses aren’t secret either. A function lives at https://YOUR-PROJECT.supabase.co/functions/v1/name, and your JavaScript names every function it calls. The question is what happens when a stranger calls one directly.
By default each function has a platform check called verify_jwt in front of it. Supabase’s authorization headers page explains what it does and doesn’t do. It turns away a request with no token. But “for migration compatibility, verify_jwt accepts publishable and secret keys on either header,” and “the check alone doesn’t authenticate a caller that sends only an API key.” The legacy anon key is itself a valid project JWT, and the engineer we mentioned in check 1 measured that it gets through as well. Your public key is in your page. So verify_jwt keeps out people who never opened your app, and nobody else.
Turning it off is worse. Supabase’s function configuration docs say it “will allow anyone to invoke your Edge Function without a valid JWT.” Look in supabase/config.toml for blocks like this:
# supabase/config.toml: a block like this lets anyone call the function, no token needed
[functions.send-invoice]
verify_jwt = falseThat’s right for a webhook or a cron job, which should check a signature or a secret key instead (our Stripe guide shows how to test a webhook). It’s wrong for anything that acts for a user.
The fix is for the function to ask who is calling, in its own code. Supabase’s Securing Edge Functions page now does this with a small wrapper from its @supabase/server package:
// supabase/functions/send-invoice/index.ts
import { withSupabase } from 'npm:@supabase/server@1'
export default {
fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => {
// Only a signed-in user gets this far. Everyone else already got a 401.
const userId = ctx.userClaims?.id
// ctx.supabase acts as that user, so your RLS policies still apply.
const { data, error } = await ctx.supabase.from('invoices').select('id, amount_cents')
if (error) return Response.json({ error: error.message }, { status: 400 })
return Response.json({ userId, invoices: data })
}),
}We ran that wrapper (version 1.4.1) locally with our own signing keys. It answered 401 to a request with nothing, to the public key in either header, and to an anon-style token with a valid signature but no user, and in none of those cases did the handler run. A real user’s token got through. Its source says why the anon key fails: a token without a sub claim “identifies no user.” If your function uses ctx.supabaseAdmin, which skips RLS, filter every query by the caller’s ID. Supabase’s docs put it bluntly: without that filter, a handler “returns every user’s rows.” If your builder wrote its own check instead of the wrapper, that can be fine too. The test below tells you.
Now test it as a stranger. Run these three, with a function that only signed-in users should be able to use:
# 1. A stranger with nothing at all
curl -i -X POST "https://YOUR-PROJECT.supabase.co/functions/v1/YOUR-FUNCTION" \
-H "Content-Type: application/json" -d '{}'
# 2. A stranger holding the public key from your page
curl -i -X POST "https://YOUR-PROJECT.supabase.co/functions/v1/YOUR-FUNCTION" \
-H "apikey: YOUR_PUBLIC_KEY" \
-H "Authorization: Bearer YOUR_PUBLIC_KEY" \
-H "Content-Type: application/json" -d '{}'
# 3. You, signed in
curl -i -X POST "https://YOUR-PROJECT.supabase.co/functions/v1/YOUR-FUNCTION" \
-H "apikey: YOUR_PUBLIC_KEY" \
-H "Authorization: Bearer YOUR_USER_TOKEN" \
-H "Content-Type: application/json" -d '{}'- The first should be a
401. If it isn’t,verify_jwtis off for that function. - The second is the real test, and it should be a
401too. If it comes back200and the function did its job, anyone who opens your page can run it. Be aware that an open function really runs when you send this, so pick one where one extra email or AI call won’t hurt. - The third should work. To get your token, sign in to your app, find a request to
supabase.coin the Network panel, and copy the part afterBearerin itsAuthorizationheader. That token lets anyone act as you until it expires, so don’t paste it anywhere but your own terminal.
If the second call got through, give your builder this:
Check every Supabase Edge Function in this project. For each one, tell me whether it should work for signed-out visitors, signed-in users only, or other services only (webhooks, cron).
- For signed-in-only functions: wrap the handler in withSupabase({ auth: 'user' }) from npm:@supabase/server@1 (or check the user at the top of the handler and return 401 when there isn't one), use ctx.supabase for database work, and if you must use the admin client, filter every query by the caller's user id.
- Keep verify_jwt on for them in supabase/config.toml.
- Only webhooks and calls from other services may have verify_jwt = false. Webhooks must verify the provider's signature first, and service calls must use withSupabase({ auth: 'secret' }).
Don't change what any function does otherwise. List the functions and what you changed.Built with Lovable or Bolt? Our Lovable guide and Bolt guide show where each builder keeps its functions and their addresses.
What a clean result means
If the root refused you, GraphQL showed nothing, the inside lists hold only what strangers really need, no security definer function is open to the public key, and your functions said 401 to test 2, you’ve done more than most apps on Supabase. Someone holding your public key now sees roughly what a signed-out visitor should see, and nothing more.
It drifts, though. Every new table, every new function, every “quick fix” for a permission error is a chance to put something back on the map. Run the two queries from check 3 again after any change to your database, and the three calls from check 9 after any change to a function. Supabase’s Security Advisor and the Data API exposure badge in the Table Editor help too. They see the inside of your project, which an outside scan can’t.
Where LockShip fits
We built LockShip to do the outside part of 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 and asks for the table list at /rest/v1/ with that key, the request from check 1. If the list comes back, the report says so (that’s the finding that links here) and the scan asks each listed table, up to 40, for at most one row, starting with the ones your code uses.
Here’s the honest limit. When Supabase refuses the list, which it should now, the scan reads your JavaScript for the tables your app asks for by name, the supabase.from('invoices') calls, up to 40 of them, and tries those plus 15 common names like profiles, orders and payments. A table your code only reaches through a variable, or one no code in the browser ever names, like an admin-only audit_log, can still go untested. The report lists every table it asked, so you can see which ones it didn’t. The query in check 3 is how you see all of them. The scan doesn’t send GraphQL queries, doesn’t guess names from error messages, and never calls your database functions: the ones your code calls with supabase.rpc() show up in the report as not tested, with a link to check 5.
For Edge Functions, it finds the names your JavaScript calls and knocks once on up to eight of them, with a HEAD request that carries no key at all, to see whether the platform turns a stranger away. It never calls one with your public key, because that would run it for real. So test 2 in check 9 is yours to do. The scan only sends GET and HEAD requests and never writes anything. A clean result means “nothing obvious from the outside right now,” not “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 builder, the RLS SQL, 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 see what your public key gives away today?
Paste your app’s address, prove you own it, and see what a stranger can read. The summary is free.
Run a free scanBefore you rely on a result, read the disclaimer.
Questions people ask
Is it a problem that my Supabase anon key is exposed?
Not by itself. Supabase says the publishable key, and the older anon key it replaces, is safe to expose with row level security enabled, and treats the anon key as something that identifies your project but is not a secret. Anyone can copy it from your page, so the questions that matter are what it can reach: which tables and database functions the anon role has grants on, what your RLS policies let through, and whether your Edge Functions check who is calling. Checks 3 to 5 and 9 in this guide answer those.
Can someone see my table names with the anon key?
On hosted Supabase, not from the API root any more: Supabase said new projects would refuse it from 11 March 2026 and existing ones from 8 April 2026. But names still leak in other ways. Older projects with GraphQL on may answer an introspection query, PostgREST’s error messages can suggest a real table name when someone guesses close to it, and your own page names the tables it reads in every request. Checks 1 and 2 show what your project gives away.
Will revoking grants break my app?
It can, if the browser still reads that table or calls that function with the public key. You’ll see it straight away: the request fails with a 42501 permission denied error whose hint names the grant that is missing. Search your code for the table name before you revoke, then test the app signed out and signed in. For a table signed-out visitors must read, keep select and take away only the write privileges.
Is verify_jwt enough to protect a Supabase Edge Function?
No. It turns away callers who send nothing, but Supabase’s own docs say the check accepts publishable and secret keys and that it alone doesn’t authenticate a caller that sends only an API key. The public key is in your page, so anyone can get past it. A function that does something for a user has to look up that user itself and return 401 when there isn’t one.
What changes for Supabase projects on 30 October 2026?
Supabase is applying its new default to all existing projects: tables you create in the public schema after that date won’t be reachable through the Data API until you grant a role access to them. Existing tables keep the grants they have, so nothing you already built stops working. You can opt in early with the four statements in check 7.
Keep reading
- Vibe coding security checklist: what to check before you launch an AI-built app
- A Supabase RLS checklist for vibe-coded apps
- Leaked API keys in your frontend bundle: how to find them and what to do
- Supabase Storage security for vibe-coded apps: public buckets, storage RLS and signed URLs
- CVE-2025-48757 explained: what happened to Lovable apps, and how to check yours
- Is my Lovable app secure? Seven checks you can do yourself
- Is my Bolt app secure? A plain check before you launch
Sources
We read each of these on 20 October 2026. Supabase is changing these defaults fast, so if something here disagrees with its docs, trust Supabase and tell us at hi@lockship.dev. We tested the SQL and the API behaviour with PostgREST 16.5 on a local Postgres (PGlite) set up with Supabase’s three API roles and its old default grants, and the Edge Function wrapper with @supabase/server 1.4.1 on Node with our own signing keys. We didn’t test against Supabase’s hosted service or its gateway, and we didn’t run any of it against anyone else’s app.
- Securing your data, Supabase docs
- API keys, Supabase docs
- Breaking change: removing access to the OpenAPI spec via the anon key, Supabase changelog
- feat(self-hosted): restrict rest root anon (#45462), Supabase on GitHub
- Locking down Supabase: the database is the security boundary, Erfi’s Lexicanum
- OpenAPI, PostgREST docs
- Configuration (openapi-mode), PostgREST docs
- Errors (PGRST205), PostgREST docs
- Hacking Moltbook: AI social network reveals 1.5M API keys, Wiz
- Breaking change: pg_graphql no longer enabled automatically, Supabase changelog
- Breaking change in pg_graphql 1.6.0: introspection disabled by default, Supabase changelog
- GraphQL security, Supabase docs
- Performance and Security Advisors (lints 0026 and 0027), Supabase docs
- Securing your API, Supabase docs
- Database API 42501 errors, Supabase docs
- System information functions (access privilege inquiry), PostgreSQL docs
- Database functions, Supabase docs
- Using custom schemas, Supabase docs
- Breaking change: tables not exposed to Data and GraphQL API automatically (#45329), Supabase on GitHub
- ALTER DEFAULT PRIVILEGES, PostgreSQL docs
- Securing Edge Functions, Supabase docs
- Authorization headers, Supabase docs
- Function configuration, Supabase docs
- verify-user-jwt.ts, @supabase/server on GitHub