Supabase is the fastest way to ship a database, auth, and storage layer—which is exactly why it powers most vibe-coded SaaS apps. It’s also secure by default… until the defaults get overridden.
In vibe-coded projects, those defaults get overridden on day one. RLS disabled “just to test,” service role keys in the frontend, storage buckets open to the internet. Here’s what to fix—and why it actually matters.
Why Supabase security is a vibe-coder’s blind spot
Supabase is the default backend for AI-assisted SaaS development. If you shipped with Cursor, Lovable, v0, Bolt, or any similar tool in the last two years, your data almost certainly lives in a Supabase Postgres database with a JavaScript client hitting it from Next.js, SvelteKit, or React Native.
That architecture is genuinely excellent. Supabase’s Row Level Security, built-in auth, and storage layer are well-designed—when configured correctly. The problem is that AI coding assistants generate working code, not necessarily secure code. And Supabase’s SDKs are deliberately permissive at setup time so developers can ship fast.
The result: founders who accept the AI diff and deploy often ship a Supabase project where RLS is off, the service role key is in the client bundle, and every table is reachable via the REST API by any visitor who extracts the anon key.
For the full picture of how vibe-coded apps accumulate security debt, see: Vibe-Coded SaaS Security: Hidden Risks (and How Founders Can Fix Them Fast)
Why Supabase misconfigurations are so common
Three patterns drive almost all of them:
- “Disable RLS to fix the 401.” When the Supabase client returns a permission denied error, disabling RLS is the fastest fix that makes the error go away. AI assistants suggest it. Developers accept the diff. The error disappears—and so does the security boundary.
- “Use the service role key—it just works.” The service role key bypasses all RLS policies. When a developer is wrestling with auth and can’t figure out the JWT flow, using the service role key in a server function makes everything work immediately. Then that edge function gets deployed, and the key is one intercepted request away from leaking.
- “Mark the bucket public so images load.” Storage buckets are private by default. Making the whole
uploadsbucket public to get profile photos loading is a sensible shortcut—until you realize the same bucket also holds user-uploaded documents, invoices, and exported reports.
None of these are careless decisions. They’re rational debugging shortcuts that never got cleaned up before launch.
The 7 most common Supabase security mistakes
1) Row Level Security (RLS) disabled on production tables
What it is: RLS is disabled by default when you create tables via SQL migrations—including those generated by AI coding tools. When disabled, any request with a valid anon key can read, write, or delete any row in that table.
Why it matters: Your anon key is visible in your frontend bundle. It’s designed to be public. Anyone with a browser and thirty seconds can extract it from your page source. If RLS is off, that person can run supabase.from('users').select('*') against your live database and dump every user record. Or run supabase.from('subscriptions').update({ plan: 'pro' }).eq('user_id', their_user_id) and upgrade themselves for free.
This is OWASP’s number-one vulnerability for a reason—broken access control—and disabled RLS is its cleanest Supabase expression.
Fix it:
- Enable RLS on every table:
ALTER TABLE your_table ENABLE ROW LEVEL SECURITY; - Then write policies. A minimal “users can only see their own rows” pattern:
CREATE POLICY "Users can view own data" ON your_table FOR SELECT USING (auth.uid() = user_id); - Write policies for SELECT, INSERT, UPDATE, and DELETE separately—they are evaluated independently.
- Refer to Supabase’s RLS documentation for the full policy syntax and testing tools.
- Shortcut check: In the Table Editor, any table with “RLS disabled” shown in red is a production risk. Start there.
2) Service role key used in frontend code or user-reachable functions
What it is: Supabase provides two main API keys—the anon key (public, safe to expose) and the service_role key (bypasses all RLS, must stay secret). The service role key is intended for backend-only use: server-side admin scripts, migrations, background workers that users cannot reach directly.
Why it matters: If the service role key leaks—or if it’s used inside a Supabase Edge Function that an end-user can call—an attacker has unrestricted read/write/delete access to your entire database, bypassing every RLS policy you wrote. It is functionally equivalent to handing a stranger the admin password.
Vibe-coded apps regularly end up with the service role key in the wrong place:
- Inside Next.js server actions or API routes that are reachable by anyone who hits the URL
- In a Supabase Edge Function that any authenticated (or even unauthenticated) user can POST to
- Accidentally bundled client-side because it was stored in an env var that wasn’t properly blocked from the browser
Fix it:
- The rule: the service role key lives only in environments users cannot reach—CLI scripts, admin jobs, cron workers on your backend.
- In every other context, use the
anonkey combined with a properly authenticated Supabase client that passes the user’s JWT. - Audit your production bundle today: open your deployed app, view source, and search for
service_role. If you find it, treat the key as leaked and rotate it immediately in the Supabase dashboard. - Also check your Edge Functions—none of them should import the service role key unless they are strictly internal with no public URL.
3) RLS enabled but policies effectively allow all
What it is: RLS is turned on, but the policy body is USING (true)—which means every authenticated user can access every row. This is better than no RLS (it requires a valid JWT), but it defeats per-user data isolation entirely.
Common variants:
USING (auth.role() = 'authenticated')— checks only that the user is logged in, not that they own the data- SELECT is locked down but INSERT/UPDATE/DELETE have
WITH CHECK (true)— write operations are wide open - Ownership is checked on SELECT but not on UPDATE, enabling IDOR (Insecure Direct Object Reference): User A can update User B’s records by supplying User B’s row ID
Why it matters: Broken access control is the most common real-world issue in Supabase apps. The application UI doesn’t show the “edit another user’s subscription” button—but the REST API endpoint will happily accept the request. All an attacker needs is curiosity and the Supabase client library.
Fix it:
- Write distinct policies for each operation.
- Every write policy must verify ownership:
CREATE POLICY "Users can update own data" ON your_table FOR UPDATE USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); - Test it: create two test accounts, log in as Account A, and try to update Account B’s row via the Supabase client directly (not through your UI). If it succeeds, your policies have a gap.
4) Storage buckets set to public
What it is: A bucket marked “public” means every file inside it is accessible via a predictable URL with no authentication. Supabase buckets are private by default; making one public is a one-click decision in the dashboard.
Why it matters: Setting the entire uploads, documents, or assets bucket to public to simplify image loading is the pattern. The problem is that same bucket then stores user-uploaded invoices, exported CSV reports, ID verification scans, or private profile attachments. Public buckets mean any file is downloadable by anyone who knows—or can enumerate—its URL. And Supabase storage URLs follow a consistent, guessable pattern.
Fix it:
- Default every bucket to private.
- Serve files to authenticated users with short-lived signed URLs:
const { data } = await supabase.storage .from('documents') .createSignedUrl('user-uploads/invoice.pdf', 60); // expires in 60 seconds - If you need publicly accessible images (marketing assets, product screenshots), isolate those in a dedicated
public-assetsbucket. Keep user-generated content in a separate, private bucket. - Audit now: Supabase dashboard → Storage → check each bucket’s access setting.
5) Edge Functions deployed without authentication
What it is: Supabase Edge Functions run on Deno Deploy and are reachable at https://[project-ref].supabase.co/functions/v1/[function-name]. By default, they require the anon key in a header—but they don’t require a valid user JWT unless you write that check yourself.
Common Edge Functions left unauthenticated in vibe-coded apps:
- Email sending endpoints (Resend, Postmark)
- AI completion endpoints (OpenAI, Anthropic calls)
- PDF generation or export functions
- Custom webhook senders
Why it matters: An unauthenticated AI completion endpoint can be called in a loop by anyone who finds the URL—a single afternoon of abuse can produce a four-figure OpenAI bill charged to your card. An unauthenticated email endpoint can send unlimited messages from your domain, triggering spam flags and destroying your deliverability. And “anyone who finds the URL” is not hypothetical—bots scan new Supabase projects within hours of creation.
Fix it:
- Validate the JWT on every function meant for authenticated users:
const authHeader = req.headers.get('Authorization') const supabaseClient = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_ANON_KEY')!, { global: { headers: { Authorization: authHeader ?? '' } } } ) const { data: { user } } = await supabaseClient.auth.getUser() if (!user) return new Response('Unauthorized', { status: 401 }) - For webhook receivers (Stripe, GitHub), skip JWT auth but validate the platform’s signature using the webhook secret.
- Add per-user rate limits on any AI-backed function. An authenticated user burning $500 of API credits is still a problem.
Related: Top 5 Security Risks Your Website Is Probably Exposed To
6) The anon key treated as a secret—or as a blank check
What it is: Two opposite misunderstandings cause two different problems. Some founders try to hide the anon key (it’s public by design—you cannot hide it). Others treat the fact that it’s public as proof that it’s harmless regardless of configuration.
Why it matters: The anon key is safe to expose only if your RLS policies are correct and your Edge Functions validate auth. If RLS is off, the anon key is a master key to your database. If an Edge Function doesn’t check for a JWT, the anon key is a ticket to call it without limits.
The anon key being public is a design choice, not a vulnerability—but it transfers the security responsibility entirely onto your RLS configuration and function auth checks. When those are weak, the public anon key amplifies the damage.
Fix it:
- Treat the anon key as a capability, not a secret. The security question is not “is the anon key safe?” but “does my RLS hold when someone uses just the anon key with no JWT?”
- Periodically test this directly: initialize a Supabase client with only your anon key (no user session) and try to query sensitive tables. You should get empty results or a permission error on every one.
7) No monitoring on auth events, no alerts on anomalies
What it is: No alerts when a founder account is accessed from a new country, no log of repeated failed login attempts, no notification when a user’s MFA settings change.
Why it matters: Attackers who get a valid credential through a third-party breach (see: Credential Stuffing Explained: How Attackers Weaponize Third-Party Breaches Against You) can sit quietly inside a compromised account reading data or waiting for a high-value action. Without monitoring, you learn about it from a customer complaint, a Stripe dispute, or a support ticket asking why their data changed.
Supabase logs authentication events—but those logs don’t do anything unless someone reads them or builds alerts around them.
Fix it:
- Review your Supabase auth logs periodically (Dashboard → Authentication → Logs).
- Build simple alerts for patterns worth noticing: high login failure rates, logins from geographies you don’t operate in, changes to email or MFA settings on admin accounts.
- Pair this with external monitoring that catches new exposed services, open ports, and publicly reachable admin routes before attackers do. A continuous view of your external surface is the complement to Supabase’s internal logs.
For a broader look at what external monitoring catches: How to Discover All Your Internet-Facing Assets
A 30-minute Supabase security audit
You don’t need a day. This catches the critical gaps in one sitting:
-
RLS check (5 min). Open Supabase dashboard → Table Editor → scan for any table showing “RLS disabled.” Enable it and write at minimum a SELECT policy that filters by
auth.uid(). Do not deploy anything until this is done. -
Key audit (5 min). Search your entire codebase (including
.envfiles committed to git) forservice_role. It should appear in zero frontend files and zero Edge Functions callable by users. If it appears anywhere else, rotate it now in Settings → API → Regenerate. -
Policy depth check (5 min). For your three most sensitive tables (users, subscriptions, anything with PII), open each table’s RLS policies. Confirm you have policies for SELECT, INSERT, UPDATE, and DELETE—and that each one checks
auth.uid() = user_id, not justauth.role() = 'authenticated'. -
Storage audit (5 min). Dashboard → Storage → check every bucket’s access level. Any bucket marked public that holds user content? Switch it to private, update your code to use signed URLs.
-
Function audit (5 min). Dashboard → Edge Functions → list every deployed function. For each one, confirm whether it validates a JWT or a webhook signature. Any that should require auth but don’t—add the auth check before next deploy.
-
Anon key test (5 min). Open your browser console on your live app. Initialize a Supabase client with just your anon key and no user session. Query your three most sensitive tables. You should get 0 rows or a permission error. If you get data, your RLS has gaps.
FAQs
Is it safe to have the Supabase anon key visible in my frontend code?
Yes—it’s designed to be public. Supabase explicitly documents the anon key as safe to expose in the browser. The security layer is RLS, not key secrecy. With RLS properly configured, the anon key alone can’t read or write data it shouldn’t. With RLS disabled, it’s a master key.
Should I use the service role key inside Next.js API routes?
Only if those routes run server-side and are not triggerable by end-users in an uncontrolled way. Never use it in middleware, in Edge Functions that users can call directly, or in any code path that executes on the client. When in doubt, use the anon key with a properly authenticated Supabase client that passes the user’s JWT.
What’s the fastest way to test whether my RLS policies actually work?
Open your browser console on your live app, initialize createClient with your anon key and no session, and run .from('sensitive_table').select('*'). If you get rows back, RLS isn’t working. You can also use the Supabase SQL editor to run SET ROLE authenticated; SET request.jwt.claims = '{"sub":"<test-user-uuid>"}'; before a SELECT to simulate a specific user’s access.
My app uses Google or GitHub login via Supabase Auth—does that change my RLS setup?
No. When a user signs in with Google or GitHub, Supabase issues a JWT that contains auth.uid(). Your RLS policies check that auth.uid() exactly the same way as email/password login. The identity provider doesn’t change the RLS evaluation.
How do I handle Stripe or GitHub webhook endpoints in Edge Functions?
Webhook endpoints must be publicly reachable (no JWT), but they must validate the platform’s signature. For Stripe, use the stripe-signature header with stripe.webhooks.constructEvent(). For GitHub, use the x-hub-signature-256 header. Never process a webhook payload without verifying its signature first.
Is Supabase inherently less secure than a self-hosted database?
No. Supabase’s RLS, built on standard PostgreSQL row security, is a robust and well-audited system. The risks in this guide are all configuration choices, not platform weaknesses. A self-hosted Postgres with no access controls is far less secure than a well-configured Supabase project.
Can I fix all of this without a security background?
Yes. The 30-minute audit above covers the critical issues. None of the fixes require security expertise—they require knowing what to look for and taking an afternoon to do it.
Final Thoughts
Supabase isn’t the problem—it’s one of the best-designed developer platforms available. The risks here come from a small set of configuration choices that are easy to make quickly and easy to forget about until someone finds them.
The founders who ship confidently on Supabase pair the speed of vibe coding with a short security loop: enable RLS, keep the service role key off the frontend, default storage to private, auth-gate your Edge Functions. None of it takes long. All of it compounds.
Want to know what your Supabase app is exposing right now—without checking manually every week?
Try Warin — continuous external monitoring with AI-generated fix guides, built for founders shipping between meetings.
Start your free trial.