RLS vs server-side checks: when to use which (Supabase)
Status: 🟩 COMPLETE Last updated: 2026-06-21 Plain-English tagline: Supabase gives you two places to enforce “who can do what” — at the database (RLS) or in your application code. Most apps need both. This is when to put a check where.
What this decides
Supabase apps have two security layers:
- Row-Level Security (RLS) — policies in SQL that the database enforces on every query, regardless of where the query came from
- Server-side application checks — code in your Server Components, Server Actions, or Route Handlers that validates permissions before/after querying
The decision: for any given protection, do you enforce it via RLS, via application code, or both?
For background: Row-Level Security 🟩, Supabase 🟩 🟦.
The short answer
Both, almost always. The default Supabase posture:
- Enable RLS on every table that contains user data. Default-deny.
- Write policies that allow only the access pattern you intend. RLS is your second-to-last line of defense.
- Also validate in server-side code. Catch bad input at the boundary; provide clear error messages.
The two layers protect against different mistakes:
- RLS protects against credential leakage and missing checks. If your
anonkey leaks, or you forget a check in code, RLS still blocks unauthorized rows. - Server-side checks protect UX and provide validation. RLS errors are cryptic (“new row violates row-level security policy”); your code can return “you can only edit your own profile” cleanly.
The factors that matter
- Where does the query originate? Client (browser) → MUST rely on RLS. Server using
service_role→ RLS is bypassed; server-side checks are the only defense. - What’s at stake? Revealing one row vs revealing all rows is a huge difference. High-stakes data → both layers.
- How complex is the rule? “User can read their own profile” → RLS is fine. “User can edit if they’re in the org AND the org has feature X AND it’s before the deadline” → server code is more readable.
- Who’s writing the code? If multiple people add features, RLS catches their mistakes too. If you’re solo, server-side checks are clear enough.
When to pick RLS (always include)
- Any table queried by
anonorauthenticatedkeys — RLS is the only thing standing between a leaked key and your data. - Multi-tenant data (users see only their own rows) — RLS using
auth.uid() = user_idis the canonical pattern. - Public-but-filtered data — e.g. “anyone can read posts, only authors can edit theirs.”
- Anything you’d describe as a “permission rule” — RLS encodes it once, enforced everywhere.
Example RLS policy:
-- Users can read their own profile rows
CREATE POLICY "users read own profile" ON profiles
FOR SELECT USING (auth.uid() = user_id);
-- Users can update their own profile
CREATE POLICY "users update own profile" ON profiles
FOR UPDATE USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);When to pick server-side checks (also include)
- For UX: turn cryptic RLS denials into friendly errors before the query runs.
- For complex multi-step logic: “if the user is owner OR a member with role X” reads cleaner in TypeScript than in SQL.
- For service_role queries: the server bypasses RLS, so RLS doesn’t protect you here. Server code is the only defense.
- For input validation: make sure the data is shaped correctly before hitting the database (avoid wasted round-trips and database errors).
- For derived authorization: “can this user perform action X right now” decisions that depend on multiple tables, time of day, or external state.
Example pattern from Bible Quest:
// /admin/passages/actions.ts
async function deletePassageAction(passageId: string) {
const user = await requireAdmin(); // server-side check
// RLS would also block non-admins via policies — both layers
await supabase.from("passages").delete().eq("id", passageId);
}When server-side ONLY (use service_role)
Some operations genuinely need to bypass RLS:
- Admin functions — your admin tool needs to read all users, not just the current user’s row.
- Background jobs — cron jobs / webhooks / scheduled tasks have no user identity.
- Data migrations and seeding — bulk operations that don’t fit a per-user pattern.
- Aggregation queries — “total points across all users” needs to see all rows.
In these cases:
- Use
service_rolekey (server-only — see Secrets management 🟩). - RLS is bypassed. Your server code is the only access control.
- Be extra-careful — anything reachable via these queries is fully exposed if you write the wrong check.
Bible Quest example: /admin/leaderboard/page.tsx uses service_role to sum points across all users. RLS isn’t helpful here (admin needs to see everyone). The admin check happens in requireAdmin().
The pattern most apps converge on
Three tiers of access:
| Tier | Where it runs | Key | RLS |
|---|---|---|---|
| Public read | Browser | anon | Policies allow specific SELECT |
| User-authenticated | Browser or Server Component | anon + JWT, or service_role server-side | RLS uses auth.uid() |
| Admin / background | Server-only | service_role | RLS bypassed; server-side check (requireAdmin) |
Most queries fall into tier 2. Tier 3 is reserved for genuinely admin-scoped operations.
Common mistakes
- Enabling RLS but not writing any policies → everything gets denied. The dashboard nags you about this; listen.
- Using
service_rolefrom a Client Component → catastrophic. The key leaks to every visitor. - Forgetting to test RLS policies → policies look right but block legitimate access. Use Supabase Studio’s “Test policy” feature.
- Trusting server-side checks alone for client queries → if the query comes from the client with the
anonkey, RLS is the only defense (server code never sees the request). - Stringing complex policies through subqueries → policies that JOIN multiple tables can be slow. Index thoughtfully or precompute.
- Writing INSERT policies as
USINGinstead ofWITH CHECK→ INSERT policies needWITH CHECK, notUSING. Easy to get wrong.
What if I’ve already chosen?
“I built everything server-side without RLS; now I want to add RLS”: good move. Enable RLS table by table, write policies, test. The transition is incremental — start with read-only policies, then UPDATE/DELETE, then INSERT.
“My RLS policies are too restrictive and breaking admin flows”: don’t loosen RLS for admins. Route admin queries through server-side service_role instead. Keep RLS strict for the user-facing path.
“I want to add a check but RLS feels too complicated”: start with server-side, then add RLS as a backstop. Both can coexist; you don’t have to choose one.
“I’m leaking the service_role key”: stop everything. Rotate the key in the Supabase dashboard. Audit what could have been read. Add a .gitignore rule. Add a pre-commit hook. Move the key to server-only env vars.
See also
- Row-Level Security 🟩 — the textbook
- Supabase 🟩 🟦
- Authentication vs authorization đźź©
- Secrets management đźź©
- Sessions and cookies đźź©
- OWASP top 10 🟩 — broken access control is #1 for a reason
- How-to: Enable Row-Level Security đźź©
- Supabase errors 🟩 🟦 — the “row-level security policy” error
- Custom auth vs Supabase Auth 🟩 — affects how
auth.uid()works