# 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: 2026-06-28
- Author: 友田 陽大
- Tags: Supabase, RLS, PostgreSQL, セキュリティ, アーキテクチャ設計
- URL: https://tomodahinata.com/en/blog/supabase-rls-getting-started-enable-first-policy-guide
- Category: Databases & RLS
- Pillar guide: https://tomodahinata.com/en/blog/supabase-production-guide-nextjs-rls-realtime-edge-functions

## Key points

- RLS is 'an implicit WHERE automatically attached to every query on a table.' The starting point is that the moment you enable it, it becomes default-deny (no one can read if there's no policy).
- Access needs two layers. GRANT (permission to operate on the table) and POLICY (which rows you may operate on). Neither alone works, and this is the biggest confusion for beginners.
- USING is 'existing rows you can see/touch,' WITH CHECK is 'new rows you may write.' SELECT/DELETE are USING only, INSERT is WITH CHECK only, UPDATE needs both.
- Supabase maps a request to anon (unauthenticated) / authenticated / service_role (RLS bypass, server-only). Express 'only my own rows' with TO authenticated and auth.uid().
- Beginners' accidents concentrate on 'forgetting enable = fully public,' 'using service_role on the client = full bypass,' and 'forgetting WITH CHECK = write bypass.' Avoid them as a type from the start.

---

"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](/case-studies/realtime-sports-scoring-app) I built single-handedly (**RLS enabled on all 69 tables, about 280 policies** in production). The content is faithful to [Supabase official](https://supabase.com/docs/guides/database/postgres/row-level-security) and [PostgreSQL official](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) (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](/blog/supabase-rls-production-multi-tenancy-patterns), [performance optimization](/blog/supabase-rls-performance-optimization-select-wrap-index-guide), [pgTAP testing](/blog/supabase-rls-testing-pgtap-policy-regression-guide) — 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
-- アプリが投げた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.

```sql
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](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)).

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.**

| 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.

```sql
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](https://supabase.com/docs/guides/database/postgres/row-level-security)).

| 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_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](/blog/supabase-anon-key-service-role-key-exposure-guide)).

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](https://www.postgresql.org/docs/current/sql-createpolicy.html)).

| 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](/blog/supabase-rls-performance-optimization-select-wrap-index-guide)).

```sql
-- 読む：自分の行だけ見える
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](/blog/supabase-rls-with-check-using-write-bypass-guide) 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`.

```sql
-- 認証済みなら誰でも読める公開プロフィール一覧
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](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)). If you want RLS to be effective even in backend processing connecting as the owner, state it explicitly.

```sql
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](/blog/supabase-rls-rbac-custom-claims-app-metadata-authorize-guide)).

### 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.

```sql
-- 二要素認証(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](/blog/supabase-rls-testing-pgtap-policy-regression-guide) 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](/blog/supabase-rls-production-multi-tenancy-patterns), [integration with Next.js](/blog/nextjs-app-router-supabase-rls-ssr-server-client-auth-guide), and [performance](/blog/supabase-rls-performance-optimization-select-wrap-index-guide). 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)

- [Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)
- [PostgreSQL: Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
- [PostgreSQL: CREATE POLICY](https://www.postgresql.org/docs/current/sql-createpolicy.html)
