DEV Community

Is the Supabase anon key safe in your frontend? What actually protects your data

If you've built an app with Lovable, Bolt or any other tool that sits on Supabase, open your live site, view the JavaScript it sends, and search for supabase.co . Right next to it you'll find a key. The first time founders see this, the question is always the same: is my database key just sitting in public? Short answer: that key is meant to be public. What it can reach is decided by your Row Level Security policies, and that's where things go wrong. Here's what the key does, which key must never ship, and three checks that tell you whether your data is actually protected. Which key is in your JavaScript? Supabase has two kinds of API keys, and the Supabase API keys guide picks between them by asking where the code runs: - Publishable key ( sb_publishable_... , or the legacyanon key): for "anything you ship: browser, mobile app, CLI, script". The docs call it "safe to expose online", because "anyone can read it, so it only reaches what Row Level Security allows." - Secret key ( sb_secret_... , or the legacyservice_role key): for "anything you control: server, Edge Function, cron job". It "bypasses Row Level Security, so it must never leave your control." If the key you found is a long string starting with eyJ , it's one of the legacy JWT keys. Supabase says it is "deprecating the anon and service_role keys by the end of 2026" in favour of the new publishable and secret keys, and both systems keep working side by side until you switch the old ones off. A lot of AI-generated code still uses the legacy names, so you'll see anon and service_role in plenty of projects for a while yet. So the key itself isn't the problem. The question is what anyone holding it is allowed to do. What the public key can do depends on your policies Every request that uses the publishable key runs as one of two Postgres roles: anon when nobody is signed in, and authenticated when a user has signed in through Supabase Auth. Your Row Level Security (RLS) policies are rules attached to those roles, and Postgres applies them to every query. The Supabase RLS guide is blunt about what happens without them: "A table in an exposed schema without RLS is readable and writable by any role with a grant on it" (Supabase: Row Level Security). In other words, if a table in your public schema has RLS switched off, anyone who copies the key out of your JavaScript can read it, and possibly change it, with a few lines of code. This isn't theoretical. In 2025 a security researcher checked 1,645 apps from Lovable's public showcase and found 170 whose Supabase tables could be read or changed with the public key, because RLS was missing or too open. It was filed as CVE-2025-48757. Lovable disputes the CVE, saying each app owner is responsible for their data, and has since added a security scan before publishing. In the apps we check, the gaps usually come from one of three places: - A table created by SQL or a migration, where RLS was never switched on. - A policy that says "anyone", often added to make an error go away, like using (true) on a table that holds personal data. - The secret key in the browser, put there by a quick fix so a failing insert would work. That one skips every policy you wrote. Check 1: is RLS on for every table? In the Supabase dashboard, open the Table Editor and look at each table in the public schema, or run this in the SQL editor: select tablename, rowsecurity from pg_tables where schemaname = 'public'; Any row with rowsecurity = false is a table the public key can reach without rules. Turn it on: alter table public.orders enable row level security; Once RLS is enabled, the docs say "no data is accessible through the API when using a publishable key, until you create policies." So expect parts of your app to go quiet until you add the policies it needs. That's the safe direction to fail in. The guide adds one more step that's easy to miss: "On projects that still grant anon and authenticated by default, revoke those grants. Adding policies doesn't remove them." Then grant back only what each role actually does in your app. Check 2: do your policies limit rows to their owner? RLS being on isn't enough if the policy lets everyone through. Look for policies on tables with user data that use true , or that are granted to anon when only signed-in users should see the data. A typical owner-only read policy from the Supabase docs looks like this: create policy "Users can view their own profile." on profiles for select to authenticated using ( (select auth.uid()) = user_id ); auth.uid() returns the ID of the signed-in user making the request, so each person only gets rows that carry their own ID. Do the same for insert, update and delete, each in its own policy, so a user can't write rows under someone else's ID. Then test it the way an attacker would, as three different people: - Signed out, using only the publishable key: you should get no private rows. - As user A: you see A's rows. - As user B: you don't see A's rows, and you can't update them. An empty result for the wrong user is what you want. One thing that trips people up: the docs note that a missing grant returns a permission error, while a policy that matches no rows returns an empty list. If a request fails that your policy should allow, check the grant before you change the policy. Check 3: is a secret key anywhere it shouldn't be? Search the JavaScript your live site sends, and your repository including its history, for sb_secret_ and service_role . Also check your environment variables: any variable prefixed for the browser, such as VITE_ or NEXT_PUBLIC_ , ends up in the build that ships to every visitor. Supabase's own example puts it in a comment: "Server-only. Never prefix these, or your bundler will ship the key." The new secret keys add a safety net: "A secret key doesn't work in a browser", because Supabase returns 401 Unauthorized when the request comes from one. But the docs are clear that it isn't protection once a key has leaked: "an attacker can still use the key from other tools." If you find one, the order matters. Supabase's advice is "Fix the root cause of the leak before you rotate anything." Move the work that needed elevated rights into an Edge Function or server route that checks who's calling, give the browser only the publishable key, then create a new secret key, switch every component to it, and only then delete the leaked one. Deleting a secret key can't be undone. I wrote up that sequence step by step in our fix for an exposed service_role or secret key. The short version The key in your JavaScript is supposed to be there. What keeps your data safe is RLS on every table, policies that limit each row to the people who should see it, and a secret key that never leaves your server. Supabase's own checklist adds one habit: review your policies regularly and run the Security Advisor in the dashboard, which "checks for common security problems with the built-in Postgres roles." If you'd like a quick outside look first, our free Lovable security check reads your live app in about a minute, no sign-up, and flags a Supabase service key or other secrets in your public JavaScript and missing security headers. It only sees what a browser sees, so the RLS tests above are still yours to run. I wrote this with AI assistance and reviewed it myself. Top comments (0)

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.