Skip to content
RowShield
All posts

RLS versus application-layer authorization

Database-enforced and app-enforced authorization fail differently - where each belongs in a Supabase product, and why layering beats choosing one.

RowShield12 min read

Should authorization live in the app or the database? The question assumes a choice that good systems don't make. Here is what each layer guarantees, how they fail differently, and the composition that gives products both workflow intelligence and unconditional isolation.

The debate is old and recurring: authorization belongs in application code where the business logic lives, or in the database where the data lives. On Supabase the question has teeth, because row-level security makes database-side enforcement genuinely excellent — and because client-facing APIs mean your "application" partially runs in strangers' browsers.

The honest answer isn't a side. Application checks and RLS policies are different tools with different guarantees, different failure modes, and different audiences. Products that understand both stop asking "which one" and start designing the split: which decisions are workflow (app-shaped) and which are boundaries (database-shaped). This article draws that line precisely.

The stakes deserve one concrete image before the principles. A team that answers "the app handles it" is claiming that every current and future code path — every route, script, integration, and internal tool — will always remember to check. A team that answers "RLS handles it" is claiming their policies cover every workflow nuance users will ever need. Both claims fail in practice; the layered design never makes either. What follows gives you the vocabulary to place each authorization decision on its correct layer, with the failure stories that show why placement matters more than implementation quality.

Two layers, two failure modes

The deepest difference isn't capability — it's what happens when each layer has a bug:

Application-layer failures are conditional. A missing permission check in one route handler affects that route. Other routes still enforce; the API still validates; nothing about the failure spreads. You find it through code review or targeted testing of that endpoint, and fixing it is a one-file deploy.

That localism has a dark twin, though: the same property that limits blast radius also multiplies surface area. Fifty routes with inline checks means fifty chances to forget, fifty code paths to test, and a new endpoint type every quarter that needs the checks re-derived. Conditional failure means small fires — but many possible fire sites, each requiring its own smoke detector.

Database-layer failures are total but visible-in-state. A dropped policy or disabled flag changes behavior for every consumer simultaneously — but the catalog records the state, probes can detect it from outside, and monitoring can catch transitions the moment they happen (the drift problem exists precisely here).

The asymmetry between the two failure modes shapes everything that follows. App-layer bugs localize but hide behind feature complexity; database-layer bugs blast broadly but sit in a small, queryable surface where a single catalog query enumerates every boundary in the system. That asymmetry drives the recommendation later: put changing, nuanced decisions where bugs localize, and invariant boundaries where violations are detectable — and where detection can be automated rather than remembered.

What the application layer does best

Authorization is often workflow, and workflow lives in application code naturally:

State machines. "An invoice can move from draft to sent, but never back" — that's transition logic over history and context, awkward to express as a row predicate and natural in a service method.

Cross-entity business rules. "Users on the free plan get three projects" requires counting rows across tables against billing state. Expressible in policy subqueries at real cost in complexity and performance; trivially in application code with a cached plan lookup.

Presentation-dependent access. Preview modes, impersonation for support, draft-sharing links with expirations — features where access depends on ephemeral context that doesn't belong inside the data model.

UX-integrated flows. Approval chains, delegated permissions, sharing with notifications — decisions that need email sending, audit logging, and UI state alongside the grant itself.

In all of these, application authorization shines because the decision needs computation over context rather than comparison against identity. Trying to force these into policies produces either elaborate definer-function machinery or, more commonly, a team decision to keep them in code — which is fine, provided the next section's rule holds: app-side decisions may gate features, but they must never be the only thing standing between a caller and rows outside their scope.

What only the database can guarantee

Then there's isolation — and here the application layer has a structural weakness no framework fixes: client-side code is adversary-readable. Any check living in browser JavaScript is advisory by nature; any API route reachable without the intended frontend sequence is a raw door. Supabase apps feel this acutely because the database is directly exposed through PostgREST with public keys — by design.

What RLS adds is an invariant: regardless of caller, path, or future code, rows outside a user's scope do not exist for them. Not "the UI hides them." Not "the current routes check." Unconditionally absent from every result set, enforced beneath every possible query shape, including queries nobody has written yet.

Three properties make this guarantee unique:

  1. It covers all paths. Today's React app, tomorrow's mobile build, the integration script, the internal tool someone built last week — every path meets the same predicate.
  2. It survives mistakes. New developers, new features, refactors that forget checks — the boundary persists independently of application discipline.
  3. It's provable. Five adversarial probes between two accounts demonstrate isolation end-to-end, per the tenant-isolation test. No equivalent proof exists for scattered route-level checks short of exhaustive endpoint auditing.

That third property deserves emphasis for teams weighing effort: provability is what turns security from an ongoing anxiety into a checked box.

A subtlety worth stating: "the database can guarantee it" assumes the policies themselves are correct and remain so — RLS is a mechanism, not magic. The guarantee is conditional on the policy set, which is why this site pairs the capability with catalog checks, probes, and drift monitoring. What RLS uniquely offers isn't perfection; it's a small, watchable surface where the guarantee lives, versus the unbounded surface of application code paths.

The interaction model: who decides what

Compose the layers by decision type rather than splitting arbitrarily:

Decision typeBelongs inExample
Tenancy boundariesDatabase (RLS)Workspace rows invisible cross-tenant
Ownership invariantsDatabase (RLS)Users modify only their own rows
Public/private content splitsDatabase (RLS)Drafts hidden until published
Workflow transitionsApplicationDraft→review→published pipeline
Entitlements & quotasApplication (+ policy reads claims)Plan limits, seat counts
Presentation accessApplicationSupport impersonation, share links

The table compresses a decade of authorization arguments into one rule of thumb: if a decision can be expressed as "rows matching this predicate," it belongs in the database; if it needs "given everything we know about this user's situation," start in the application — and check that whatever the app decides cannot expose rows the policies forbid.

Read the table bottom-up as a review discipline too: anything in the application half should be incapable of violating the database half. Impersonation may let support staff see everything in the UI, but the underlying tenancy predicates still bound what any query returns unless elevation is explicit and audited. Quotas may gate the "create project" button, but the projects table's policies don't depend on the quota being honored — they depend on ownership, period.

This ordering also dictates testing: database-half invariants get the probe battery; application-half rules get unit/integration tests. Different layers, different proofs.

A worked example of the split

One feature, both layers, cleanly divided: "Pro users get unlimited projects; free users cap at three."

The database layer owns ownership and tenancy — unconditional and boring on purpose:

create table projects (
  id uuid primary key default gen_random_uuid(),
  owner_id uuid not null,
  name text not null,
  created_at timestamptz not null default now()
);

alter table projects enable row level security;

create policy "projects_select"
  on projects for select
  to authenticated
  using ((select auth.uid()) = owner_id);

create policy "projects_insert"
  on projects for insert
  to authenticated
  with check ((select auth.uid()) = owner_id);

Note what's absent: no plan check, no count. The database guarantees that whatever projects exist, users see and create only their own — forever, for every caller.

The application layer owns entitlement — with context only it has:

const { count } = await supabase
  .from("projects")
  .select("*", { count: "exact", head: true })
  .eq("owner_id", user.id);

if (plan === "free" && count >= 3) {
  return upsellScreen();
}

Now consider failure modes. If the app's count logic breaks, a free user might create project four — an overage, handled by billing, not a breach. If a policy breaks, cross-owner visibility appears — a breach, caught by probes. Neither failure masquerades as the other; each layer's tests target its own guarantees; and the plan value itself flows through a signed claim both layers read consistently. That is the composition working as designed: workflow intelligence upstairs, unconditional isolation downstairs, claims as the verified interface between them.

When each layer alone fails

Two miniature post-mortems make the case better than principles:

App-only, boundary leak: A SaaS enforces workspace visibility in its Next.js server components — thorough middleware, well-tested. Eighteen months later, a partner integration hits PostgREST directly (it was always enabled), fetching documents?select=* with a service key embedded in the partner's onboarding bundle. Every check existed in the app; the database had none. Isolation was a convention, and conventions don't bind new paths.

DB-only, workflow chaos: Another team encodes everything in policies, including a five-stage approval flow expressed through subqueries over an approvals table plus date comparisons plus role lookups. It works — until stage three gains an exception requiring notification, and the policy mutates into a definer function writing rows during SELECT evaluation. Complexity compounds; performance degrades; nobody can review the expression anymore.

The tell in the second story is reviewability: policies exist partly so that access can be read by humans. When an expression requires holding five tables and a state machine in your head, it has stopped functioning as a reviewable boundary — which matters because boundaries get audited precisely when trust is uncertain, i.e., exactly when complexity has accumulated. Application code tolerates complexity better (tests, types, debuggers); policies reward being small enough to verify at a glance.

Both teams made the same mistake from opposite directions: assigning boundary-shaped work to the app, or workflow-shaped work to the database. The fix in both cases is the split — invariants down into policies, workflow up into code, with the interface between them explicit (entitlements flowing through claims, transitions validated before writes).

The layered recommendation

For a typical Supabase product, concretely:

  1. RLS owns the floor: enable everywhere, write four-command ownership/tenancy sets per table, index predicate columns — the full pattern from our first-policy-set walkthrough.
  2. The app owns the flow: workflow gates, entitlements, UX-conditioned access — implemented above the database, assuming the floor holds.
  3. Claims bridge them: entitlement flags ride in verified JWT claims (app_metadata), so app-side quota logic and policy-side conditions read the same signed source.
  4. Each layer gets its own tests, and drift monitoring watches the floor while conventional CI watches the flow.

Step 3 deserves its own sentence of caution: claims are only as fresh as token issuance, so entitlements that must revoke instantly (a downgraded plan, a banned account) need server-side enforcement too — either a table the policies join against or a short token lifetime. Claims carry identity efficiently; they don't carry immediacy.

Two failure-driven corollaries complete the design. First, when the two layers disagree, the database wins by construction — app logic may be wrong about a quota, but it cannot make cross-owner rows appear. Second, every escalation path (support impersonation, admin tooling) must pass through the boundary explicitly — service-grade access logged and inventoried per the role boundaries reference, never silently assumed.

The result isn't redundancy — it's specialization. The database stops pretending to understand billing cycles; the application stops pretending its checks bind unknown callers. Each layer does what it's structurally good at, and the seams between them become the interesting places to review rather than the scary ones.

Common questions

Doesn't duplicating checks in both layers create maintenance drag?

Avoid literal duplication — the goal is layered responsibility, not the same rule written twice. Tenancy lives only in policies; quotas live only in the app. Where both must know something (plan tiers), the claim is written once and read by both. Teams that report double-maintenance pain are usually duplicating rather than dividing.

If my RLS is complete, can I skip app-side authorization entirely?

Only if your product genuinely has no workflow-shaped decisions — rare beyond toys. But the inverse matters more: skipping RLS because the app checks things is the far costlier cut, since app checks cannot bind direct-API callers. When effort is scarce, harden the floor first; workflow refinement tolerates iteration, boundaries don't tolerate holes.

How do policies read application-computed values?

Through verified channels: custom claims in the JWT (set via auth hooks, covered in Supabase's hooks documentation), or dedicated tables the app maintains under its own policies. What policies must never do is trust request-supplied values — parameters and payloads are inputs, not authority. Two cautions apply when a policy reads another table (entitlements, memberships): that table needs its own policies letting the caller read the relevant rows, because subqueries inherit caller scope, and the join belongs on an indexed key. When the data changes frequently or originates in an external billing system, prefer syncing it into claims or a small local table over live subqueries into remote schemas — keeping the boundary fast enough that nobody is tempted to bypass it for performance.

Where do roles and permissions matrices fit in this split?

Roles that mean tenancy or ownership (admin sees all workspaces) belong in policies; roles that mean features (editor can export CSV) belong in the app reading the same role source. The confusion arises when one word — "admin" — silently means both. Name them separately at the data layer (a role column policies can read) versus the capability layer (entitlement flags), even when your product UI presents them as one concept.

Does client-side permission logic have any legitimate place?

Only as UX — hiding buttons and routes users can't use, for clarity rather than security. The test: remove every client-side check and ask what an attacker gains. If the answer is "nothing but a uglier UI," the checks are presentation. If the answer is "data," those checks were load-bearing and must move below the API boundary into policies.


Curious whether your floor currently holds? Run the free scan — paste your app URL and measure the database layer's actual guarantees, independent of what your application intends.

RowShield is an independent product and is not affiliated with, endorsed by, or sponsored by Supabase, Inc.

RowShield checks what a deployed Supabase app exposes: a free anonymous, read-only probe, and scheduled policy-metadata and drift checks for connected projects. Run a free audit.

RowShield is a Veristria product. More about RowShield.