Mockly

glossary

Row Level Security (RLS)

Row Level Security (RLS) is the row-by-row gatekeeper that blocks anon/authenticated clients from touching rows they do not own, but it only protects data when it is enabled and forced. This page explains it in plain English, then goes deeper into how it works in Supabase/Postgres, what commonly goes wrong, and how to fix it without relying on fragile client-side rules.

What “Row Level Security (RLS)” means (plain English)

RLS evaluates a table-level policy for every read or write and checks the session role, JWT claims, and helper functions before letting a row flow to the client.

How Row Level Security (RLS) works in Supabase/Postgres (technical)

When enabled, Postgres runs the USING expression for SELECT/DELETE and the WITH CHECK expression for INSERT/UPDATE; forcing RLS ensures those expressions fire even for owner-like roles while grants still control which roles start a session.

Attack paths & failure modes for Row Level Security (RLS)

  • RLS missing exposes user profiles: The landing page bundled the anon key and kept the REST call to public.profiles in place after removing the profile feed from the UI.
  • RLS enabled but not forced: Developers relied on owner-level access for internal scripts and skipped FORCE ROW LEVEL SECURITY to keep those flows working.
  • RLS missing exposes user profiles: RLS was never enabled, so the anon key could call the API and enumerate every row.
  • RLS enabled but not forced: Unforced RLS let owner and superuser sessions read data without hitting the policies, so some environments behaved as if the table was open.
  • RLS is disabled, so any policies you thought existed are not enforced at all.
  • RLS is enabled but not forced, and privileged roles can still bypass row checks.
  • Policies exist but are written too broadly (for example, they don’t bind rows to a user or tenant).
  • Grants allow reads/writes even though the app “looks private” in the UI.
  • Authorization logic lives only in the frontend; attackers can call PostgREST directly.

Why Row Level Security (RLS) matters for Supabase security

Without properly enforced RLS, any credentials that reach the table (often the anon/authenticated keys in your bundle) can read or modify every row, turning private data into a public endpoint.

Common Row Level Security (RLS) mistakes that lead to leaks

  • Believing a table is protected because a policy exists even though RLS is disabled or not forced.
  • Leaving broad SELECT/UPDATE grants on anon or authenticated while assuming policies will override them.
  • Trusting client-side filtering instead of letting the database enforce row-level restrictions.
  • RLS missing exposes user profiles: RLS was never enabled, so the anon key could call the API and enumerate every row.
  • RLS enabled but not forced: Unforced RLS let owner and superuser sessions read data without hitting the policies, so some environments behaved as if the table was open.

Where to look for Row Level Security (RLS) in Supabase

  • Table RLS flags: whether RLS is enabled and whether it’s forced for sensitive tables.
  • Existing policies and their expressions (are you actually restricting rows?).
  • Grants for anon and authenticated on tables, views, and functions that touch sensitive data.
  • Any client-side code paths that query tables directly (REST, client libraries).

How to detect Row Level Security (RLS) issues (signals + checks)

Use this as a quick checklist to validate your current state:

  • Try the same queries your frontend can run (anon/authenticated). If sensitive rows come back, you have exposure.
  • Verify RLS is enabled and (for sensitive tables) forced.
  • List policies and look for conditions that don’t bind rows to a user or tenant.
  • Audit grants to anon / authenticated on sensitive tables and functions.
  • RLS missing exposes user profiles: Confirm RLS is enabled on every sensitive table before shipping.
  • RLS missing exposes user profiles: Frontend-only filters disappear once direct REST calls exist.
  • RLS missing exposes user profiles: Backend-only endpoints centralize authentication and auditing.
  • Re-test after every migration that touches security-critical tables or functions.

How to fix Row Level Security (RLS) (backend-only + zero-policy posture)

Mockly’s safest default is backend-only access: the browser should not query tables, call RPC, or access Storage directly.

  1. Decide which operations must remain client-side (often: none for sensitive resources).
  2. Create server endpoints (API routes or server actions) for required reads/writes.
  3. Apply hardening SQL: enable+force RLS where relevant, remove broad policies, and revoke grants from client roles.
  4. Generate signed URLs for private Storage downloads on the server only.
  5. Re-run a scan and confirm the issue disappears.
  6. Add a regression check to your release process so drift doesn’t reintroduce exposure. Fixes that worked in linked incidents:
  • RLS missing exposes user profiles: Enable and force RLS, drop the broad grants, and shift the profile fetch to a backend endpoint that uses service_role.
  • RLS enabled but not forced: Force RLS on every sensitive table, revoke the extra grants, and route backend actions through secure service endpoints.

Verification checklist for Row Level Security (RLS)

  1. Attempt a direct table read using the same anon/authenticated credentials your app ships with.
  2. Confirm RLS is enabled (and forced for sensitive tables) and that policies are scoped to ownership/tenancy.
  3. Revoke broad grants and verify direct client access now fails with 401/403.
  4. Ensure your backend endpoints still work and enforce authorization explicitly.
  5. Re-run a scan after the change and after the next migration to catch drift.
  6. RLS missing exposes user profiles: Confirm RLS is enabled on every sensitive table before shipping.
  7. RLS missing exposes user profiles: Frontend-only filters disappear once direct REST calls exist.
  8. RLS missing exposes user profiles: Backend-only endpoints centralize authentication and auditing.

SQL sanity checks for Row Level Security (RLS) (optional, but high signal)

If you prefer evidence over intuition, run a small set of SQL checks after each fix.

The goal is not to memorize catalog tables — it’s to make sure the access boundary you intended is the one Postgres actually enforces:

  • Confirm RLS is enabled (and forced where appropriate) for tables tied to this term.
  • List policies and read them as plain language: who can do what, under what condition?
  • Audit grants for anon/authenticated and PUBLIC on the tables, views, and functions involved.
  • If Storage is involved: review bucket privacy and policies for listing/reads.
  • If RPC is involved: review EXECUTE grants for functions and whether privileged functions are server-only.

Pair these checks with a direct API access test using client credentials. When both agree, you can ship the fix with confidence.

Over time, keep a small “query pack” for the checks you trust and run it after every migration. That’s how you prevent quiet regressions.

Prevent Row Level Security (RLS) drift (so it doesn’t come back)

  • Add a checklist query to CI that fails if new tables ship without RLS enabled/forced.
  • Document a rule: the browser should not query sensitive tables directly (backend-only by default).
  • Review policies in code review the same way you review authentication changes.
  • Keep one reusable verification test for “RLS missing exposes user profiles” and rerun it after every migration that touches this surface.
  • Keep one reusable verification test for “RLS enabled but not forced” and rerun it after every migration that touches this surface.

Rollout plan for Row Level Security (RLS) fixes (without breaking production)

Many hardening changes fail because teams revoke direct access first and only later discover missing backend paths.

Use this sequence to reduce both risk and outage pressure:

  1. Implement and verify the backend endpoint or server action before permission changes.
  2. Switch clients to that backend path behind a feature flag when possible.
  3. Then revoke direct client access (broad grants, permissive policies, public bucket reads, or broad EXECUTE).
  4. Run direct-access denial tests and confirm authorized backend flows still succeed.
  5. Re-scan after deployment and again after the next migration.

This turns security fixes into repeatable rollout mechanics instead of one-off emergency changes.

Incident breakdowns for Row Level Security (RLS) (real scenarios)

RLS missing exposes user profiles

Scenario: The landing page bundled the anon key and kept the REST call to public.profiles in place after removing the profile feed from the UI.

What failed: RLS was never enabled, so the anon key could call the API and enumerate every row.

What fixed it: Enable and force RLS, drop the broad grants, and shift the profile fetch to a backend endpoint that uses service_role.

Why the fix worked: Postgres now enforces the policy before returning rows, so the anon key is rejected and only the backend service with service_role can read data.

Key takeaways:

  • Confirm RLS is enabled on every sensitive table before shipping.
  • Frontend-only filters disappear once direct REST calls exist.
  • Backend-only endpoints centralize authentication and auditing.
  • Re-scan after schema or policy changes to catch regressions.

Read full example: RLS missing exposes user profiles

RLS enabled but not forced

Scenario: Developers relied on owner-level access for internal scripts and skipped FORCE ROW LEVEL SECURITY to keep those flows working.

What failed: Unforced RLS let owner and superuser sessions read data without hitting the policies, so some environments behaved as if the table was open.

What fixed it: Force RLS on every sensitive table, revoke the extra grants, and route backend actions through secure service endpoints.

Why the fix worked: Forcing RLS causes Postgres to evaluate the policy even when privileged roles are active, removing the bypass that drifted across environments.

Key takeaways:

  • Forcing RLS removes silent bypasses introduced by owner-like sessions.
  • Privileged scripts should call backend endpoints instead of hitting tables directly.
  • Consistency across environments prevents surprise leaks.
  • Add migrations that force RLS to stop future drift.

Read full example: RLS enabled but not forced

Real-world examples of Row Level Security (RLS) (and why they work)

Related terms

  • Public Table Exposure → /glossary/public-table-exposure
  • Over-permissive RLS Policies → /glossary/over-permissive-rls-policies

FAQ

Is Row Level Security (RLS) enough to secure my Supabase app?

It’s necessary, but not sufficient. You also need correct grants, secure Storage/RPC settings, and a backend-only access model for sensitive operations.

What’s the quickest way to reduce risk with Row Level Security (RLS)?

Remove direct client access to sensitive resources, enable/force RLS where appropriate, and verify via a repeatable checklist that anon/authenticated cannot query what they shouldn’t.

How do I verify the fix is real (not just a UI change)?

Attempt direct API queries using the same client credentials your app ships. If the database denies access (401/403) and your backend endpoints still work, your fix is effective.

Next step

Want a quick exposure report for your own project? Run a scan in Mockly to find public tables, storage buckets, and RPC functions — then apply fixes with verification steps.

Explore related pages

parent

Glossary

/glossary

sibling

Over-permissive RLS Policies

/glossary/over-permissive-rls-policies

sibling

Public Table Exposure

/glossary/public-table-exposure

cross

RLS missing exposes user profiles

/examples/row-level-security/rls-missing-exposes-profiles

cross

RLS enabled but not forced

/examples/row-level-security/rls-enabled-but-not-forced

cross

Pricing

/pricing