Supabase row level security is one of those features that sounds complicated until you see exactly one working example. Then it clicks and you realize how much safer your project just became for almost no work. Most guides jump straight to multi-tenant SaaS patterns with org IDs and role hierarchies. That's fine, but I want to address a narrower case first: you're the only person who will ever touch this database, your app runs server-side, and you're wondering whether RLS is even necessary. The answer matters, and it's not what I expected when I first wired up Supabase.

What Supabase Row Level Security Actually Does

The shortest accurate description: RLS adds an invisible WHERE clause to every query that hits a table. You define the clause. Postgres enforces it, at the storage engine level, before your application code ever sees a row.

When you enable RLS on a table in Supabase, the behavior flips to deny-by-default immediately. No rows come back. No rows can be written. Until you add at least one policy, the table is effectively locked even to authenticated users. That is not a bug. That is the whole point.

Policies are written in SQL and attached to specific operations: SELECT, INSERT, UPDATE, DELETE. Each policy has two optional clauses:

  • USING: applied to rows that already exist. Controls what rows are visible for SELECT, UPDATE, and DELETE.
  • WITH CHECK: applied to the row being written. Controls whether an INSERT or UPDATE is allowed to land.

If you write a policy for UPDATE with only a USING clause and no WITH CHECK, Postgres uses the USING expression for both. That's fine in most single-owner cases. But in multi-user apps, that gap can let someone update a row they can read but shouldn't be allowed to own after the edit. Worth knowing.

The Two Helper Functions You'll Use

auth.uid() returns the UUID of the currently authenticated user as Supabase sees it. It reads from the JWT that Supabase Auth issues after login. This is what you'll use in 90% of your policies.

auth.jwt() returns the full decoded JWT as a JSON object. Useful when you're storing custom claims, like a role or a plan tier, directly in the token. You'd access a claim with something like (auth.jwt() ->> 'role'). For a single-operator project, you probably won't need this.

Roles: anon vs authenticated vs service_role

Supabase maps every request to a Postgres role. The three you care about:

  • anon: unauthenticated requests. Anything made with the public anon key and no session token runs as this role.
  • authenticated: requests made with a valid Supabase Auth session. RLS policies using auth.uid() only fire for this role.
  • service_role: the admin key. Bypasses RLS entirely. Never expose this in a browser or a public environment variable.

Grants matter independently of policies. Even if a policy would allow a row, the role needs SELECT/INSERT/UPDATE/DELETE granted on the table. Supabase does this automatically for anon and authenticated when you create a table through the dashboard, but if you're running raw SQL migrations, double-check your grants.

Do You Need Supabase Row Level Security If You're the Only User?

Here's the question no guide answers directly. My take: yes, with one specific carve-out.

If your app accesses Supabase exclusively through a backend you control, using the service_role key, and that backend is never exposed to arbitrary user input, you can get away without RLS. The service_role key bypasses it anyway, so your policies would do nothing for those queries. Your security perimeter is your server.

But here's the situation I actually found myself in when building the autonomous blog agent I described in this post: the project started server-only and then I added a lightweight dashboard so I could review drafts from my phone. That dashboard used the anon key and Supabase Auth. Suddenly I had a browser-facing surface, a JWT in local storage, and a table full of content I absolutely did not want exposed if I misconfigured something.

Enabling RLS took about ten minutes. Not enabling it and later realizing a policy was missing would have taken one accidental misconfiguration and a lot of stress.

The carve-out: if it is purely server-to-server, no browser client, no user-facing auth, and you're disciplined about keeping the service_role key out of any public context, RLS is not doing work for you. But if there's any chance the project grows a UI, add RLS now. Retrofitting it later means auditing every table and every policy for gaps you can't see.

The Minimal Viable Policy Set for a Solo Project

For a single-operator tool where you authenticate with Supabase Auth, the policy set is small. Here's what I actually run for tables that store my own data:

-- Enable RLS
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

-- Allow the authenticated owner to read their own rows
CREATE POLICY "owner can select"
ON posts
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);

-- Allow inserts only from the authenticated owner
CREATE POLICY "owner can insert"
ON posts
FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = user_id);

-- Allow updates only from the authenticated owner
CREATE POLICY "owner can update"
ON posts
FOR UPDATE
TO authenticated
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);

-- Allow deletes only from the authenticated owner
CREATE POLICY "owner can delete"
ON posts
FOR DELETE
TO authenticated
USING (auth.uid() = user_id);

That's it. Four policies. Every query that goes through the Supabase JS client with an active session will be filtered to rows where user_id matches your UID. If you're the only user, every query returns what you'd expect and nothing else.

You do need a user_id column on each table, typed as uuid and referencing auth.users(id). Set a default of auth.uid() on insert so you don't have to pass it manually from application code:

ALTER TABLE posts
  ADD COLUMN user_id uuid NOT NULL DEFAULT auth.uid()
  REFERENCES auth.users(id);

Now every INSERT automatically stamps the row with your UID. Your application code doesn't have to think about it, which means there's no code path where you forget to set it.

What I'd Actually Do: My Policy Approach for Side Projects

When I start a new Supabase project, I enable RLS on every table immediately, even before I write application code. I do this before I've decided whether there will ever be a UI. The cost is near zero and the habit means I never ship a table that's accidentally open.

For tables that are purely internal and always accessed by my backend with the service_role key, I still enable RLS but I don't add policies. The service_role bypasses RLS, so my backend works fine. But now if I accidentally use the wrong key somewhere, I hit a deny-by-default wall instead of leaking data silently. That's the outcome I want from a failed configuration: loud failure, not silent exposure.

For tables touched by a browser client or any auth-gated UI, I add the four-policy owner pattern shown above as my starting point. That takes less than five minutes and handles the entire single-user case.

The tradeoff I accepted: slightly more SQL to manage per table. On a project with eight tables, that's 32 policies. That sounds like a lot until you realize each policy is four lines of nearly identical SQL. I keep them in a migrations/ folder, one file per table, and apply them with the Supabase CLI. Version controlled, reviewable, repeatable.

Testing That Your Policies Actually Work

This is the step most people skip and then regret. You can test RLS policies directly in the Supabase SQL editor by temporarily impersonating a role.

-- Test as an authenticated user with a specific UID
SET LOCAL role TO authenticated;
SET LOCAL request.jwt.claims TO '{"sub": "your-user-uuid-here", "role": "authenticated"}';

SELECT * FROM posts;

If your policies are correct, you should see only the rows owned by that UID. If you see everything, something is wrong. If you see nothing and expect rows, check your user_id values and your grants.

You can also test as anon:

SET LOCAL role TO anon;
SELECT * FROM posts;

If RLS is on and you have no policy granting anon access, this should return zero rows. Confirm that. Don't assume it.

I run these checks manually every time I add a table. It takes about two minutes and has caught misconfigured policies twice in projects I've shipped.

Performance: One Thing That Actually Matters

When your policy references a column like user_id, Postgres evaluates that filter on every row scan. On a small table, this is invisible. On a table with hundreds of thousands of rows, a missing index on user_id will hurt. Supabase's own documentation notes that unindexed policy columns are one of the most common causes of unexpected query slowness after enabling RLS.

Add the index:

CREATE INDEX ON posts (user_id);

One line. Do it when you create the table, not after you notice a slow query in production.

The other performance consideration is SECURITY DEFINER functions. If you have a policy that calls a function, that function runs with the privileges of its definer by default if you mark it SECURITY DEFINER. This is sometimes useful for bypassing RLS inside a trusted function, but it can also silently bypass protections you intended to keep. I avoid SECURITY DEFINER unless I have a specific reason and I document what it's doing with a comment directly in the migration file.

Bypassing RLS for Admin Tasks

The service_role key bypasses RLS entirely. Use it in your backend, in n8n workflows, in cron jobs, anywhere that doesn't involve a user session. Never put it in a NEXT_PUBLIC_ environment variable or anywhere a browser can reach it.

In practice this means I have two Supabase clients in most projects:

  • A browser-safe client initialized with the anon key. Used in the UI. Subject to RLS.
  • A server-only client initialized with the service_role key. Used in API routes, Edge Functions, and automation workflows. Bypasses RLS.

The split is intentional. The server client is powerful and private. The browser client is restricted and public. If I ever see the service_role key in a client-side bundle, that's a critical bug, not a configuration choice.

Does Supabase Realtime Respect RLS?

Yes, with a caveat. As of mid-2024, Supabase Realtime enforces RLS on broadcast and presence channels when you enable it. For database changes via the postgres_changes listener, RLS is checked against the subscribing user's JWT. If your policy would deny them the row in a normal SELECT, they won't receive the realtime event for that row either.

This works as expected for single-owner setups. I subscribe as my authenticated session and I only receive events for rows I own. The behavior is consistent with what I'd get from a direct query, which is what I want.

RBAC Patterns: When You Actually Need Them

Role-based access control with RLS comes into play when you have multiple users with different permission levels. For a single-operator project, you don't need this. But since this is a teardown and the question comes up, here's the shape of it.

The two common approaches:

Custom claims in the JWT. You store a role like admin or editor in the user's JWT via a Supabase Auth hook. Then your policy reads it with auth.jwt() ->> 'role'. Fast because the claim is already in the token, no extra query needed.

A roles table in the database. You store user-to-role mappings in a table and join against it in your policy. More flexible because you can change roles without reissuing tokens. Slower because every policy check hits the database for the role lookup. Use a SECURITY DEFINER function and cache it if the table gets large.

For Elev8 client projects where there's an owner and a few staff members, I've used the JWT claim approach. It keeps policies simple and avoids the join cost. For anything with dynamic role changes, the database table approach is the right call even with the overhead.

The Honest Limits of RLS

RLS is not a complete security solution on its own. It protects data at the row level inside Postgres, but it does not protect against:

  • A compromised service_role key. If that leaks, RLS is irrelevant.
  • Insecure API routes that fetch data server-side and return more than the caller should see. RLS only fires when the query reaches Postgres with the right role context.
  • Misconfigured policies. A policy that's technically syntactically correct but logically wrong will pass no tests until you think to test the specific case it breaks.

I treat RLS as one layer. The other layers are: keeping the service_role key out of any public context, validating input before it reaches Supabase, and testing policies explicitly rather than assuming they're correct because they compiled.

If you're building anything beyond a personal tool, and especially if you're building for clients through an agency like Elev8, the combination of RLS, server-side key management, and explicit policy tests covers the common failure modes. That's the setup I'd recommend and the one I actually run.

If you're building something for your business and want this infrastructure set up correctly from the start, reach out to Elev8. I build these systems for clients and can help you avoid the gaps I had to find the hard way.