Skip to main content
友田 陽大
Databases & RLS
Supabase
RLS
PostgreSQL
セキュリティ
アーキテクチャ設計

Supabase RLS for beginners: writing your first policy — the basics of enabling, GRANT, and anon/authenticated, with the sticking points

A beginner's guide that carefully explains Supabase (PostgreSQL) row-level security (RLS) from zero to writing your first policy. With official-compliant real code: enable row level security, SELECT/INSERT/UPDATE/DELETE policies, USING/WITH CHECK, the two-layer model of GRANT and RLS, anon/authenticated/service_role, and the 5 traps beginners always fall into.

Published
Reading time
10 min read
Author
友田 陽大
Share

"I created a table in Supabase, and anyone could read all rows with the anon key" — start using Supabase without knowing RLS, and almost everyone steps on this accident once. Conversely, the moment you enable RLS, you're bewildered that "now even I can't read a single row." Untangling the true identity of these two bewilderments is the goal of this beginner article.

When you finish reading, you'll be able to write your first RLS policy with confidence, explain why it's secure, and avoid from the start the 5 traps beginners always fall into. As a subject, I weave in the design decisions of the real-time match-recording app with multi-user simultaneous editing I built single-handedly (RLS enabled on all 69 tables, about 280 policies in production). The content is faithful to Supabase official and PostgreSQL official (as of June 2026).

The positioning of this article: this is a beginner's guide handling "the very first of RLS." The path after you can write — multi-tenant production design, performance optimization, pgTAP testing — connects to each article. First, solidify the foundation here.


1. What RLS is: an "implicit WHERE" automatically attached to every query

Row-Level Security (RLS), in a word, is a WHERE clause the database automatically appends to every query on a table.

For example, write a policy on the profiles table that "you can only see your own rows," and even if the app throws the defenseless SQL select * from profiles, PostgreSQL internally executes this.

-- アプリが投げたSQL
select * from profiles;

-- PostgreSQLが実際に実行するSQL(ポリシーが暗黙のWHEREとして付く)
select * from profiles where (auth.uid() = user_id);

This is decisively important. This where doesn't exist in the app-side code but on the DB side. So —

  • It works no matter which client it comes from (browser, mobile, curl, someone else's script)
  • It works even if you forget to write where user_id = ? in the app code
  • It works automatically even when you add a new endpoint

Whereas app-side differentiation like if (user.id === row.ownerId) "leaks if you forget to write it," RLS rejects it as the last line of defense at the DB. This is RLS's value, and it means protecting authorization with "the DB's structure" rather than "the app's goodwill."


2. The starting point: it becomes "default-deny" the moment you enable it

The first SQL is this. Enable RLS on the table.

alter table public.profiles enable row level security;

Here, something that always surprises beginners happens. Right after enabling, if you've written not a single policy, no one (other than the table owner) can read a single row. In PostgreSQL official's words, "if no policy exists, default-deny is applied, and rows are neither visible nor changeable" (PostgreSQL: Row Security Policies).

This is a feature, not a bug. RLS's design philosophy is the whitelist (allow-list) approach of "first close everything. Add permission explicitly, one at a time." So the procedure is always this order.

  1. enable row level security to close everything
  2. create policy ... to open only the necessary permissions

Don't do the reverse. "Open everything for now and close it later" is fully public the moment you forget to close it. Close, then open — this is RLS's iron rule.


3. The biggest sticking point: GRANT and POLICY are different (the two-layer model)

This is what confuses beginners the most, so let's crush it first. To access a table in Supabase, you need to pass two separate gates, both.

LayerWhat it decidesSyntaxAnalogy
GRANT (permission)Whether you may operate on the table at all (per table)grant select on ... to authenticatedThe key to enter the building
POLICY (RLS)Which rows you may operate on (per row)create policy ... using (...)Entry permission per room

You can access only when both are true. Without a GRANT it's rejected before RLS, and even with a GRANT, if there's no policy it's rejected by default-deny. Much of "I wrote a policy but can't access" and "I disabled RLS but can't access" is a mix-up of these two layers.

In Supabase, creating a table in the dashboard often automatically attaches GRANTs to the anon / authenticated roles, so it works without being conscious of the GRANT layer — which becomes a source of confusion instead. The explicit GRANTs the official docs show are these.

grant select on public.profiles to anon;
grant select, insert, update, delete on public.profiles to authenticated;
grant select, insert, update, delete on public.profiles to service_role;

Mnemonic: GRANT is "whether you can touch the table (coarse)," and POLICY is "which rows you can touch (fine)." RLS further narrows the GRANT, not replaces it.


4. As whom is it executed: anon / authenticated / service_role

Before writing a policy, understand "as whom Supabase executes a request." Supabase maps a received request to one of three Postgres roles depending on the presence of a JWT (token) (Supabase official).

RoleWhen it's usedRLSWhere to use
anonNo token (not logged in)EffectiveReading public data
authenticatedA valid user token (logged in)EffectiveNormal app operations
service_roleUsing the service-role keyBypassesServer-side admin processing only

Here's the most important warning for beginners.

The service_role key completely bypasses RLS. Absolutely don't place it in a browser, mobile app, or public repository. It's for the server (a trusted environment) only. Always use the anon (publishable) key on the client.

The accident of the service_role key leaking to the client = the meaning of writing all your RLS vanishing, never stops happening. Avoid key mix-ups alone as a type from the beginner stage (details: anon/service_role key exposure).

To know "who the current user is" inside a policy, use auth.uid() (the user ID) and auth.jwt() (the entire token). When unauthenticated, auth.uid() returns null.


5. Writing your first policy: USING and WITH CHECK

A policy has two places to write the condition. Their roles are completely different, so grasp this accurately first.

  • USING (...) — a check on existing rows. Only rows where it becomes true are "visible / touchable." = a read-side filter
  • WITH CHECK (...) — a check on new rows born from INSERT/UPDATE. If false, it's rejected. = a write-side filter

Which is effective in which command is the map of policy design (PostgreSQL: CREATE POLICY).

CommandUSING (existing rows)WITH CHECK (new rows)
SELECT✅ effective
INSERT✅ effective
UPDATE✅ effective✅ effective
DELETE✅ effective

Write "can read/write only my own profiles row" as 4 policies per command. Wrapping with (select auth.uid()) and a subquery is the habit of writing in a performance-effective form from the start (reason: performance optimization).

-- 読む:自分の行だけ見える
create policy "Users can view own profile"
on public.profiles for select
to authenticated
using ( (select auth.uid()) = user_id );

-- 作る:自分のuser_idを持つ行しか作れない(他人になりすませない)
create policy "Users can insert own profile"
on public.profiles for insert
to authenticated
with check ( (select auth.uid()) = user_id );

-- 更新:自分の行だけ対象にでき(USING)、更新後も自分の行のまま(WITH CHECK)
create policy "Users can update own profile"
on public.profiles for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );

-- 消す:自分の行だけ消せる
create policy "Users can delete own profile"
on public.profiles for delete
to authenticated
using ( (select auth.uid()) = user_id );

Why INSERT needs WITH CHECK

INSERT has no "existing row," so USING can't be used. Instead, check the "new row you're trying to create" with WITH CHECK. with check ((select auth.uid()) = user_id) means "you can only create a row that has your own ID in the user_id column." Without writing this, an authenticated user can fabricate a row with someone else's user_id. A table that "can only read its own rows" coexisting with a hole that "can write someone else's rows" — this is the write bypass from a missing WITH CHECK.

When you want to let public data be read

For "everyone who's logged in can read," add using (true); to show it to non-logged-in users too, add to anon.

-- 認証済みなら誰でも読める公開プロフィール一覧
create policy "Public profiles are viewable by signed-in users"
on public.profiles for select
to authenticated
using ( true );

using (true) means "no row-based restriction (read everything within the GRANT's range)," and it's intended public access. The problem is making it true unintentionally. So keep yourself in a state where you can always explain "why is this row OK to make public."


6. One step further: FORCE, auth.jwt(), restrictive

To close the beginner's guide, let me touch lightly on three concepts you'll need soon. For deep dives, go to each link.

Make the table owner obey RLS too: FORCE

In PostgreSQL, the table owner normally bypasses RLS (PostgreSQL official). If you want RLS to be effective even in backend processing connecting as the owner, state it explicitly.

alter table public.profiles force row level security;

Judge by JWT claims: auth.jwt()

When you want to judge by only the token's contents without hitting the DB, such as role or team membership, use auth.jwt(). But only app_metadata (unmodifiable) may be used for authorization, and using user_metadata, which the user can modify, for authorization is strictly forbidden (advanced: RBAC design).

Fix an "absolutely non-removable condition" with AND: restrictive

A normal policy (permissive) is combined with OR and "adds permission." In contrast, as restrictive is combined with AND and "applies a constraint to the whole." Fix non-removable conditions like a tenant boundary or an MFA requirement with restrictive.

-- 二要素認証(aal2)を通したセッションでなければ更新を一切許さない(全UPDATEにANDで効く)
create policy "Require MFA for updates"
on public.profiles as restrictive for update
to authenticated
using ( (select auth.jwt()->>'aal') = 'aal2' );

7. The 5 traps beginners always fall into (checklist)

Finally, the "first accidents" I've seen over and over in the field, paired with how to avoid them. Point-and-confirm these 5 before deploying.

#TrapSymptomAvoidance
1Forget enableYou think you wrote RLS but all rows are publicenable on all tables in the public schema. Check for non-enabled in CI
2Use service_role on the clientRLS is all bypassed and rendered meaninglessThe client is anon/publishable key only. service_role is server-limited
3Forget WITH CHECKCan't read but can write someone else's rowsAlways WITH CHECK on INSERT/UPDATE. Don't be reassured by SELECT tests alone
4Confuse GRANT and POLICY"I wrote a policy but it doesn't work / deleted it but it works"Recall the two-layer model. It passes only when both are true
5Tests only for "allow"Can't notice a regression that loosened a policyAlso test with pgTAP that "what should be rejected is rejected"

Conclusion: RLS is "close, then open explicitly"

  • RLS is an implicit WHERE attached to every query. The last line of defense that protects authorization with the DB's structure, not the app's goodwill.
  • enable row level security makes it default-deny, and create policy opens permissions one at a time.
  • Access needs both layers of GRANT (table permission) and POLICY (row permission).
  • USING = existing rows you can see, WITH CHECK = new rows you can write. Don't forget WITH CHECK on INSERT/UPDATE.
  • service_role bypasses RLS — don't place it on the client.

Once you've taken the first step, next go to multi-tenant design, integration with Next.js, and performance. RLS isn't "write it and you're done"; it withstands production on the four points of design, integration, performance, and testing.

Primary sources (always confirm the latest)

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading