"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.
enable row level securityto close everythingcreate 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.
| Layer | What it decides | Syntax | Analogy |
|---|---|---|---|
| GRANT (permission) | Whether you may operate on the table at all (per table) | grant select on ... to authenticated | The 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).
| Role | When it's used | RLS | Where to use |
|---|---|---|---|
anon | No token (not logged in) | Effective | Reading public data |
authenticated | A valid user token (logged in) | Effective | Normal app operations |
service_role | Using the service-role key | Bypasses | Server-side admin processing only |
Here's the most important warning for beginners.
The
service_rolekey 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 theanon(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 becomestrueare "visible / touchable." = a read-side filterWITH CHECK (...)— a check on new rows born fromINSERT/UPDATE. Iffalse, it's rejected. = a write-side filter
Which is effective in which command is the map of policy design (PostgreSQL: CREATE POLICY).
| Command | USING (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.
| # | Trap | Symptom | Avoidance |
|---|---|---|---|
| 1 | Forget enable | You think you wrote RLS but all rows are public | enable on all tables in the public schema. Check for non-enabled in CI |
| 2 | Use service_role on the client | RLS is all bypassed and rendered meaningless | The client is anon/publishable key only. service_role is server-limited |
| 3 | Forget WITH CHECK | Can't read but can write someone else's rows | Always WITH CHECK on INSERT/UPDATE. Don't be reassured by SELECT tests alone |
| 4 | Confuse 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 |
| 5 | Tests only for "allow" | Can't notice a regression that loosened a policy | Also 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 securitymakes it default-deny, andcreate policyopens 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_rolebypasses 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.