Back in 2021, a SaaS client came to me about six weeks after launch. Their product was a project management tool. Nice UI, solid onboarding, decent retention. Then one of their beta users noticed something: by tweaking the project_id in a GET request, they could read another user's data. No auth bypass. No SQL injection. Just a missing policy. The table had RLS enabled, but the SELECT policy was wide open, USING (true). Someone had cargo-culted it from a tutorial and never looked back.
That incident stuck with me. Since then, I treat RLS as architecture, not an afterthought. And after building north of 12,000 sites and apps at Seahawk, I have a handful of policies I write almost reflexively, before I write a single line of frontend code.
This is that list.
Why RLS Is Worth the Friction
Supabase runs on PostgreSQL, which means Row Level Security is a first-class database feature, not a bolt-on. When you enable RLS on a table and a user queries it through the Supabase client, every row gets filtered through your policies automatically. No matter what your API layer does or doesn't do.
That last part is the point. I've worked with teams using REST, GraphQL, edge functions, and background jobs all hitting the same database. Enforcing access control at the application layer across all of those is a coordination nightmare. Enforcing it at the database layer is just... done.
Honestly, the friction of writing policies pays for itself the first time a junior dev forgets to add a WHERE clause.
There's one thing people misunderstand though. Enabling RLS without any policies doesn't mean "open access." It means no access at all for normal roles. Every row is denied by default. So if you enable RLS and your app breaks immediately, that's why.
The Four Tables I Always RLS-Protect First
Not everything needs the same level of scrutiny. But these four table types get locked down before I touch anything else.
- User profile tables (
profiles,users,accounts): The obvious one. Users should read their own row. Maybe admins can read all. Nobody should write to someone else's profile. - Resource/content tables (
projects,documents,posts): Whatever the core "thing" in your app is. Ownership is usually straightforward here. - Billing and subscription tables: If you're using Stripe and storing plan data, subscription status, or invoice history in Supabase, this needs to be locked tight. I've seen apps accidentally expose trial end dates to other users.
- Audit logs: These are read-only for users (if at all). Only service role should write them.
The Policies I Actually Write
1. The Owner-Only Policy (My Most-Used Pattern)
This is the one I write more than anything else. Simple premise: users can only see rows they own.
`` create policy "Users can view own rows" on profiles for select using (auth.uid() = user_id); ``
auth.uid() is a Supabase helper that returns the UUID of the currently authenticated user. Clean, fast, indexed if user_id is indexed. I pair this with an insert policy that sets user_id to auth.uid() by default, so users can't insert rows pretending to be someone else.
`` create policy "Users can insert own rows" on profiles for insert with check (auth.uid() = user_id); ``
The with check clause is for write operations. using is for reads. A lot of people get these mixed up and end up with policies that look right but don't actually prevent bad inserts.
2. The Org/Team Policy (Multi-Tenant Apps)
This is where things get interesting. For any multi-tenant SaaS, I need users to see rows belonging to their organisation, not just themselves.
The pattern I land on: a memberships join table that links users to organisations.
`` create policy "Org members can view org resources" on projects for select using ( exists ( select 1 from memberships where memberships.org_id = projects.org_id and memberships.user_id = auth.uid() ) ); ``
Seahawk had a fintech project where the org had dozens of users, some with read-only roles, some with write access. We extended this pattern with a role column on memberships and used it directly in the policy. So a viewer role couldn't run UPDATE or DELETE at the database level, full stop. Not enforced by the API. Enforced by the database.
3. The Public Read / Owner Write Policy
For content that's publicly visible but only editable by the owner. Blog posts, public profiles, product listings.
``` create policy "Anyone can read published posts" on posts for select using (published = true);
create policy "Authors can update own posts" on posts for update using (auth.uid() = author_id) with check (auth.uid() = author_id); ```
Two separate policies. I see people try to combine these into one and end up with logic that's hard to reason about. Keep them separate. Postgres will OR them together for the same operation automatically when needed.
4. The Service Role Escape Hatch
Some operations legitimately need to bypass RLS. Background jobs, webhooks, admin scripts. For these I use the service_role key, which bypasses RLS entirely.
But here's the thing: I never expose the service role key in frontend code. Ever. It lives in environment variables on the server side only. I've reviewed codebases where it was hardcoded in a Next.js pages/ directory. That's your entire database, wide open.
If you're using Supabase edge functions, you can use the service role client inside them safely, because edge functions run server-side. Supabase's own docs on auth and service roles are worth reading cover to cover if you haven't.
5. The Admin Override Policy
For apps with an admin panel, I add a policy that grants admins full access, checked against a role stored in the user's JWT metadata or a separate user_roles table.
`` create policy "Admins can do everything" on projects for all using ( exists ( select 1 from user_roles where user_roles.user_id = auth.uid() and user_roles.role = 'admin' ) ); ``
I used to store roles in the JWT custom claims, which is faster (no subquery), but it means you have to re-issue the JWT whenever a role changes. For most apps, the subquery is fine. If you're seeing performance issues at scale, JWT custom claims via Supabase Auth hooks is the move.
Common Mistakes I've Made (And Seen)
Let me be direct about the things that have actually bitten me or my clients.
- Forgetting UPDATE and DELETE policies. It's easy to write a SELECT policy and feel like you're done. You're not. Test all four operations: SELECT, INSERT, UPDATE, DELETE. I use the Supabase dashboard's built-in policy tester now, but for years I was writing raw SQL in psql and testing manually.
- USING vs WITH CHECK confusion.
USINGfilters which rows a query can see .WITH CHECKvalidates whether a write operation is allowed. For UPDATE, you need both:USINGto control which rows can be targeted,WITH CHECKto control what the row looks like after the update. - Recursive policy loops. If your policy on table A queries table B, and table B has a policy that queries table A, you'll get infinite recursion. I hit this once with a
teamsandteam_memberstable that referenced each other. The fix: use security definer functions to break the cycle. - Not testing as an anonymous user. Supabase lets you use the anonymous role (
anon). Always test your policies as bothauthenticatedandanon. I use Postman with different auth tokens to simulate this, switching between no token, a valid user token, and a different user's token. - Leaving RLS off on storage buckets. RLS applies to the
storage.objectstable too. If you create a Supabase Storage bucket and leave that table unprotected, anyone can read your "private" files if they guess the path. I learnt this the hard way on a client project that stored user-uploaded documents.
How I Test My Policies Before Shipping
This is my actual process, not a theoretical checklist.
- Write the policy in the Supabase SQL editor.
- Open a second browser tab, sign in as a different test user.
- Try to access data that should be blocked. Confirm it's blocked.
- Try to access data that should be visible. Confirm it works.
- Run an UPDATE and DELETE against a row I don't own. Should fail.
- Check the Supabase logs for any
row-level security policy violatederrors.
For anything complicated, especially multi-tenant org policies, I write a small test script using the supabase-js client with two different user sessions and assert the expected results. Takes maybe 20 minutes to write, saves hours of debugging in production.
When to NOT Use RLS
RLS is not always the right tool.
If you're building an internal admin tool where all users are trusted employees, RLS adds complexity without much return. A simple server-side auth check is fine. If your data model is so complex that policies require 5-level deep subqueries, you might be better off enforcing access control in an API layer with proper service decomposition.
Also: if you're using Supabase purely as a backend with your own API in front of it (never exposing the Supabase URL or anon key to clients), RLS is optional. The API becomes your security layer. That said, I still add basic policies in those cases because defence in depth is worth it.
Look, RLS is a tool. Not a religion. Use it where it makes your system simpler and safer. Don't cargo-cult it because a tutorial told you to.
FAQ
Do I need RLS if I'm only using Supabase with a server-side API?
Not strictly. If your frontend never touches Supabase directly and everything goes through your own server, your API is the security layer. But I still recommend adding at least owner-based policies as a second line of defence. If someone finds a bug in your API, RLS catches what falls through.
Does RLS affect performance?
It can, if your policies involve expensive subqueries on large tables. The fix is almost always indexing. Make sure the columns used in your policy conditions (user_id , org_id , etc.) have indexes. On a project last year, adding an index on org_id dropped policy evaluation time from ~40ms to under 2ms on a table with 800k rows.
Can I use RLS with Supabase Realtime?
Yes. Realtime subscriptions respect RLS policies. If a user subscribes to changes on a table, they'll only receive events for rows their policies allow them to see. This is one of the genuinely nice design decisions in Supabase's architecture.
What's the difference between `for all` and writing separate policies?
for all creates a single policy covering SELECT, INSERT, UPDATE, and DELETE. It's convenient for admin override patterns. For everything else, I write separate policies per operation because the conditions are usually different. SELECT might allow public reads while INSERT requires ownership. Separate policies are easier to reason about when something goes wrong at 11pm.
How do I debug a policy that's blocking requests it shouldn't?
First: check auth.uid() is actually returning a value. If the user isn't authenticated, it returns null and most policies will fail. Second: temporarily set the policy to USING (true) to confirm the query itself works. Third: add a test policy that logs the values you're checking (using a security definer function that raises a notice). The Supabase dashboard logs also surface RLS violations which makes this a lot less painful than it used to be.
---
Security at the row level isn't glamorous work. Nobody's writing blog posts about the breach that didn't happen. But that incident in 2021 with the exposed project data taught me that the gap between "RLS enabled" and "RLS done correctly" is wider than most people think. These policies close that gap. At least they do for me.