Security & Privacy

Users Can See Other Users Data in Supabase: How to Fix It

J
James Eriksson
··17 min read
Supabase tables default to public. Learn why users see each other's data, how to detect it in 5 minutes, and fix it with RLS policies in 30 minutes. Test with two accounts.
TL;DR
  • By default, Supabase tables have zero row-level security: anyone with your app's public API key can read all rows
  • Single-user testing masks this vulnerability completely; a second user always discovers it within hours of production launch
  • Fix it in 30 minutes: enable RLS with SQL, write user_id-matching policies, test with two separate accounts
  • Service role keys bypass RLS entirely; if exposed in frontend code, an attacker has full database access
  • Ship's managed platform pre-enables RLS and scans schemas before deployment, eliminating this misconfiguration class

Your Supabase app works perfectly when you're the only user testing it locally. Then your first customer logs in and immediately emails you: they can see another customer's data. You didn't build an API endpoint for it. They're accessing tables through your app's normal queries. This is the most common Supabase security misconfiguration in production applications, and it exists because Row Level Security defaults to disabled on all new tables.

Why This Happens: It's Not a Supabase Bug, It's a Configuration Default

Supabase tables have no security by default. When you create a new table, anyone with your app's public API key can read, create, update, or delete every row. This is intentional. Supabase prioritizes flexibility over safety assumptions, which means you choose when to enforce rules. The moment you don't choose, you inherit full-table exposure.

The permission model works this way: your app has two keys. The anon key is embedded in your frontend and JavaScript bundle. The service_role key is secret, used only on your backend. When a user accesses Supabase through the anon key without RLS policies, they see everything. When your backend uses the service_role key, it also sees everything by design (you control the backend logic). The vulnerability emerges when a developer assumes private data and wires the anon key to your tables without writing Row Level Security policies.

Supabase has 100,000 GitHub stars and millions of rows of documentation on RLS. The framework itself is not broken. Your table configuration is. The Supabase docs open with a clear warning: "By default, all new tables in Supabase are public, unless you enable Row Level Security." You read this during setup and likely thought "Yes, I'll do that later." Then you shipped to production before "later" came. This is why Vibe App Scanner identifies tables without RLS as the number one critical security finding for vibe-coded projects.

Single-user testing masks the problem entirely. You have one session in the browser, one user ID in your auth context. There is no second row to query that doesn't belong to you. All queries return exactly what you expect. The problem sleeps. It wakes up when your second user (a paying customer, a beta tester, or a competitor running a free trial) logs in, opens your app, and discovers they can query the orders table for all customers or read the profile data for all users.

How to Detect This in Your Deployed App Right Now

You need to know right now whether your live app is exposed. Do not assume RLS is enabled because you remember enabling it. Do not assume your policies are working because your app works. Test it. You have three methods, each taking under five minutes.

Method 1: Check Which Tables Lack RLS with a SQL Query

Open your Supabase dashboard, navigate to SQL Editor, and paste this query:

SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND NOT EXISTS (
  SELECT 1 FROM information_schema.role_table_grants
  WHERE information_schema.role_table_grants.table_name = information_schema.tables.table_name
  AND privilege_type = 'SELECT'
  AND is_grantable = true
)
ORDER BY table_name;

This returns the names of tables with no RLS enabled. If you see any production table here, stop. Your data is exposed. The output tells you exactly which tables need fixing before your next customer signup.

Method 2: Test with Two User Accounts in Your App

This is the real-world test. Create two separate accounts in your app's auth system using two different email addresses. Log in as User A, note down their user_id from the URL or your app state. Switch to an incognito window, log in as User B. Now browse through the app as User B and try to access resources that belong only to User A.

Can you see User A's orders? Their profile? Their uploaded files? If yes, RLS is not working. If you can modify User A's data from User B's account, RLS is definitely not working.

This test reveals policy bugs too. Enabling RLS is not the same as writing working policies. An RLS policy that says "anyone can select all rows" technically enables RLS but doesn't restrict access.

Method 3: Use Vibe App Scanner

Vibe App Scanner is a third-party security audit tool for Supabase projects. It scans your entire database and generates a report of critical findings. Tables without RLS show up in red. You point it at your Supabase project (you control the permissions), and it returns a severity-ranked list of security issues in minutes. It costs nothing to run and tells you immediately what's broken.

When you run Vibe App Scanner and see red rows labeled "Tables Without RLS," each one is a confirmed exposure vector. You have your fix list right there.

Why a Second User Always Finds This

Local development uses one account. You test your entire app as yourself. You create a post and read it. You upload a file and download it. Every query returns the rows that belong to you because you're the only user in the database. The logic looks correct. The app works.

Production uses multiple users. Your first customer logs in, and the same queries now return rows from all users. The app still works. The queries still execute. But now there's data that doesn't belong to the logged-in user, and they see it.

Why does this only surface with a second user? Because RLS policies compare the logged-in user's ID with a column in the table. A policy might say: "Let this user select only rows where user_id equals auth.uid()." When you're the only user in the database, every row has user_id equal to your user_id (because you created them). The policy silently allows access. The moment a second user exists, they query rows with a different user_id, the policy denies access, and the app either shows empty results or fails the query.

If RLS is disabled (not just misconfigured), the policy engine doesn't run at all. All rows are visible to all users with the anon key. This is worse than a policy bug. This is no security at all.

Single-user testing is standard practice during development, and it works fine for feature testing. It fails catastrophically for security testing. You need a second user with no special privileges to see whether your access controls actually control access.

This is why the issue reaches production so often. The app ships. The first customer signs up and immediately sees data they shouldn't. The developer gets an email or a support ticket: "I can see other users' data in your app." This is the moment they discover RLS was never configured or never tested.

In real time, this typically happens within hours of production launch. The time between "ship the app" and "someone tries to access someone else's data" is nearly zero once you have two users.

The 5-Minute Fix: Enable RLS on Every Table

RLS is not automatically enforced on existing tables. You enable it explicitly with SQL. Open your Supabase SQL Editor and run this command for each affected table:

ALTER TABLE table_name ENABLE ROW LEVEL SECURITY;

Replace table_name with the actual table name. If you have 10 tables to fix, run 10 commands. If you have 50 tables, run 50 commands. Do not create a loop script and run it blindly; run them one at a time and verify each succeeds.

After enabling RLS, every query without a valid policy will be denied. Your app might break. That is correct. It means RLS is working. The next step (writing policies) will unbreak it.

Do not skip the next step. Enabling RLS without writing policies locks your users out of their own data. The fix is not complete. You have knocked on the door; now you need to unlock it.

Test the fix: Return to the SQL Editor and run:

SELECT COUNT(*) FROM table_name;

If this query errors with a permission denied message, RLS is enabled. If it returns a count, either RLS is not enabled or you have an overly permissive policy (which you haven't written yet, so this shouldn't happen).

Enabling RLS does not break the Supabase dashboard itself. You can still see all rows in the dashboard because the dashboard authenticates as the service role. Your app, which uses the anon key, will have restricted access.

Do this for every table in your public schema. Do it immediately. Do not wait. This is not a nice-to-have or a future improvement. This is a security breach in progress.

The 30-Minute Fix: Write Proper RLS Policies

Enabling RLS is step one. Writing policies is step two. A policy defines who can do what with which rows. Without policies, RLS blocks everything. With bad policies, RLS blocks nothing. You need to write policies that are specific enough to be safe and broad enough to not break your app.

The most common pattern is tenant isolation: users can only see their own data. Here is the policy:

CREATE POLICY "Users can only see their own data"
ON public.orders
FOR SELECT
USING (auth.uid() = user_id);

This reads as: "For the orders table, when selecting rows, allow access only if the authenticated user's ID matches the user_id column in that row." The USING clause is the filter. It runs against every row. If the condition is true, the row is included. If false, it is excluded.

Write this policy for every table that holds user-specific data. Replace "orders" with your table name and "user_id" with your actual user-identifying column. If your table has a user_id column, use this pattern.

For INSERT, UPDATE, and DELETE operations, write separate policies:

CREATE POLICY "Users can insert their own orders"
ON public.orders
FOR INSERT
WITH CHECK (auth.uid() = user_id);

The WITH CHECK clause for INSERT and UPDATE ensures that users can only create or modify rows where they are the owner.

CREATE POLICY "Users can update their own orders"
ON public.orders
FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can delete their own orders"
ON public.orders
FOR DELETE
USING (auth.uid() = user_id);

Now your orders table allows select, insert, update, and delete operations, but only for the user who owns that row.

Test these policies locally using role impersonation. Supabase CLI lets you test policies before deploying:

supabase test db

This command discovers test files in your supabase/tests directory and runs them using pgTAP, a PostgreSQL testing framework. You write tests that impersonate different user roles and verify that policies behave correctly. A test might impersonate user_id=1, query the orders table, and verify it returns only user_id=1's orders. Another test impersonates user_id=2 and verifies it returns an empty set or a permission error.

Testing locally catches policy bugs before production. It is the equivalent of the two-user app test but at the SQL level. You see exactly what each user can and cannot access.

Common mistakes to avoid:

  1. Overly permissive USING clauses. A policy that says USING (auth.uid() IS NOT NULL) allows any authenticated user to see all rows. The condition is always true. Add the user_id comparison.

  2. Forgetting to write UPDATE and DELETE policies. If you write only a SELECT policy, users can read each other's data but not modify it. You still have a breach. Write all four policies.

  3. Using string comparisons instead of UUIDs. If your user_id column is a UUID but auth.uid() returns a string, the comparison fails silently. Cast both to the same type: auth.uid()::text = user_id::text.

  4. Writing policies that reference nonexistent columns. Double-check your column names. A typo will silently fail the policy condition, and access will be denied for everyone.

The 30-minute estimate assumes you have 5-10 tables, clear naming conventions, and no nested roles or complex permission structures. Complex apps with tenant isolation, admin roles, and shared data take longer. Start with the simple pattern, test it, and refactor once it works.

Preventing This Before Production: A Ship Checklist

You can avoid this entire problem by catching it before launch. Ship has a pre-production security checklist. Use it.

Before your first paying customer signs up, answer these questions:

  1. Have you enabled RLS on every table that holds user-specific data? Run the SQL query from Method 1 above. If any production tables appear, fix them now.

  2. Have you written RLS policies for every table? Check your database. Are there policies defined? Are they correct? Do a two-user test (Method 2) to verify they work.

  3. Have you tested your app with at least two separate user accounts? Log in as different users and try to access each other's data. Can you? If yes, your policies are not working.

  4. Have you reviewed your Supabase API keys? Is the service_role key buried in your frontend code? Is the anon key properly scoped? The service_role key should never be visible in the browser.

  5. Have you audited your Supabase dashboard settings for public schemas, public functions, or public storage buckets? These are explicit, intentional settings that allow unauthenticated access. Verify they are correct.

Your deployment pipeline should not reach production without answering "yes" to all five questions. See the detailed /hosting/ship/checklist-before-your-first-paying-customer for a complete pre-launch walkthrough.

Ship's platform includes pre-deployment security scanning. Before your app goes live, Ship runs automated checks for common RLS misconfigurations. It catches tables without RLS, overly permissive policies, and exposed keys. Developers who use Ship rarely ship this vulnerability because the platform flags it first.

Service Role Key Leaks: Another Critical RLS Killer

RLS works because it enforces row-level access control on the anon key. The service_role key bypasses RLS entirely. This key is meant to be used only by your backend, in a secure, server-side context. If the service_role key leaks into your frontend, RLS protection evaporates.

Service role leaks happen when:

  1. A developer hardcodes the service_role key in the app's JavaScript and commits it to GitHub.

  2. A CI/CD configuration file exposes the key as an environment variable in a build artifact.

  3. A developer copies the key into their app for quick testing and forgets to remove it before committing.

  4. A customer or competitor reverse-engineers your app's requests and finds the key in network traffic.

Once the service_role key is public, anyone with that key can query all tables in your Supabase project, bypassing RLS policies, reading and modifying all data. This is worse than RLS misconfiguration. This is complete database access.

How to prevent it:

  1. Store the service_role key only in backend environment variables, never in the app code.

  2. Use the anon key in the frontend.

  3. Create a server-side API layer that accepts requests from your app, validates the user's session, and queries Supabase using the service_role key on their behalf.

  4. Rotate your service_role key if you suspect it has been exposed.

Audit your codebase right now. Search for "service_role" or paste your service_role key value and search for it. If it appears in any frontend file, JavaScript bundle, or version-controlled code, treat it as compromised and rotate it in the Supabase dashboard.

Opsily's managed Ship platform segregates API keys by environment. Frontend code never has access to the service_role key. The key is injected into backend containers only, and network access is controlled. This architectural separation eliminates most leaking scenarios.

Your Next Steps: Testing and Hardening

You have three paths forward: fix it yourself, automate the fix, or move to managed hosting.

Path 1: Manual Fix Enable RLS on your tables, write policies, test with two users, and verify. The process takes 30 minutes to an hour if you have 5-10 tables. It takes longer if you have 50 tables or complex permission rules. You stay on your current hosting (Supabase Cloud, Vercel, Coolify, or self-hosted). You own the security process. You own the testing process. You own the deployment timing.

Path 2: Automated Scanning Before you decide where to host, use Vibe App Scanner to identify all security gaps. It is free, takes minutes, and generates a full report. You now have a prioritized list of fixes. You can tackle them in order of severity.

Path 3: Managed Hosting Opsily's Ship platform manages this for you. New Supabase projects created through Ship have RLS pre-enabled on all tables. Your database schema is scanned before deployment. Service role keys are securely segregated. You focus on your app logic; Ship handles the security scaffolding.

For most small teams (under 20 people), the manual fix is realistic. For teams shipping under deadline or teams that have already been burned by a security incident, managed hosting eliminates the category of mistake entirely.

If you choose managed hosting, Ship includes a GDPR-compliant app hosting setup out of the box. See /hosting/ship/gdpr-compliant-app-hosting for details. Your data is in Europe, encrypted, and audited.

The low-friction path is Supabase plus two-user testing. The zero-risk path is managed hosting with pre-secured defaults. The competitive but harder path is DIY self-hosting with Coolify or similar, where you manage RLS and security yourself.

Choose based on your team's security expertise and tolerance for risk. No path is wrong. All paths require you to understand RLS before shipping.

Frequently Asked Questions

Does Supabase have access to my data?

Supabase runs your database and stores your rows. Supabase employees can access the raw data using database credentials, which is true of any hosted database service. If you require data isolation from the provider itself, you must self-host. For most SaaS startups, Supabase's security and privacy policies are sufficient. You still must enable RLS to isolate user data from other app users.

Are Supabase databases public?

By default, yes. Any table without RLS enabled and without authentication requirements is readable via your app's public API key. This is not a bug; it is the default state. You change it by enabling RLS and writing policies. A Supabase database is only as private as your configuration makes it.

Should I enable row level security?

Yes. Enable it on every table that holds user-specific or sensitive data. RLS is not optional. It is the boundary between a single-user app and a multi-user app. If your app has two or more users with different access rights, RLS must be enabled.

How safe and secure is Supabase?

Supabase itself is secure infrastructure. The vulnerability is not in Supabase; it is in misconfiguration. A developer who writes correct RLS policies and keeps API keys secret has a secure app. A developer who skips RLS or leaks keys has an insecure app. The framework is not the problem.

How can I disable row level security in Supabase?

You can disable RLS with: ALTER TABLE table_name DISABLE ROW LEVEL SECURITY. This returns the table to the fully public state where anyone with the anon key can read and write all rows. Do not do this for tables holding user data. There is no legitimate reason to disable RLS once you have enabled it.

What is RLS, row level security?

RLS is a PostgreSQL feature that filters rows based on the authenticated user's identity. You write policies that check the user's ID against the table rows. Only matching rows are returned or modified. It is the foundation of multi-user data isolation in Supabase.

The Bottom Line

Your Supabase app likely has no RLS because Supabase defaults to none. This is not a secret or a bug. It is your responsibility to enable it and test it with real multi-user scenarios. The moment a second user logs in, the vulnerability is exposed. The fix is straightforward: enable RLS, write policies, test with two accounts. It takes 30 minutes and prevents months of damage control.

If you are shipping soon and this feels like a distraction, consider Ship's managed hosting: the platform catches this problem before deployment and eliminates the entire class of misconfiguration. You focus on the app. Ship handles the defaults.

Ship catches this before it ships
Pre-deployment security scanning and GDPR-compliant defaults mean you never deploy with exposed user data.
Get Started Free

Ready to self-host your own apps?

One server. Multiple apps. No per-app fees.

Get started →