DEV Community

Mark F A
Mark F A

Posted on

Postgres RLS in Local Dev: The Three Silent-Fail Modes That Ship to Prod

TL;DR

  • RLS has a brutal failure property: when it is misconfigured, everything works. Queries return rows, tests pass, nothing errors
  • Silent-fail 1: your migration tool (raw SQL, dbmate, Prisma) creates tables with RLS disabled by default
  • Silent-fail 2: your local dev connection runs as a role with BYPASSRLS, so policies are never exercised even when they exist
  • Silent-fail 3: views execute with the view owner's privileges, so RLS on the underlying tables is skipped unless you opt in (Postgres 15+)
  • All three are catchable with about 30 lines of SQL that run at boot in ~50ms

The nasty thing about row level security is that it fails open in development. A missing policy doesn't throw. A bypassed policy doesn't warn. Your app happily reads and writes, your test suite goes green, and the first person to discover the gap is a curious user in production changing an ID in a URL.

Here are the three ways I've seen that happen, and the boot-time check that makes all of them loud.

Silent-fail 1: migrations don't enable RLS

Whatever writes your DDL, the default is the same. This table has no row security:

create table user_notes (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null,
  body text
);
Enter fullscreen mode Exit fullscreen mode

Raw SQL migrations, dbmate, Prisma's generated DDL: none of them add enable row level security for you. You have to remember two lines, every table, forever:

alter table user_notes enable row level security;
alter table user_notes force row level security;
Enter fullscreen mode Exit fullscreen mode

That second line matters more than people think. Without force, the table's owner still bypasses RLS entirely. If your app connects as the role that ran the migrations, you have policies that apply to nobody.

Silent-fail 2: dev is seeded (and queried) under BYPASSRLS

Even with RLS enabled and policies written, they only apply to roles that are subject to them. Superusers and any role with the BYPASSRLS attribute skip the whole mechanism.

And that is exactly how most local stacks are wired: you seed and often query as postgres or an equivalent admin role. So locally, every policy is a no-op. The first connection that actually exercises your policies is the production one, which is the worst possible place to run a policy for the first time.

The fix is to make local dev connect the way production does: as a non-privileged role, with the admin role reserved for migrations only.

Silent-fail 3: views don't inherit RLS (until you ask)

Views in Postgres execute with the privileges of the view's owner, not the querying user. Create a convenience view over a policied table and you have quietly built a door around your own policies:

create view recent_notes as
  select * from user_notes order by created_at desc limit 100;
-- runs as the owner: RLS on user_notes is not applied to callers
Enter fullscreen mode Exit fullscreen mode

Postgres 15 added the opt-in fix:

create view recent_notes
  with (security_invoker = true) as
  select * from user_notes order by created_at desc limit 100;
Enter fullscreen mode Exit fullscreen mode

With security_invoker, the view runs as the caller and RLS applies normally. On anything older than 15 there is no clean equivalent, which is one of several reasons local dev should run the same modern Postgres major as production.

The boot-time check that catches all three

None of these need discipline. They need a tripwire. This runs at application boot (or as the last migration) and fails loudly on all three modes:

do $$
declare bad text;
begin
  -- 1: tables without RLS (or without FORCE)
  select string_agg(tablename, ', ') into bad
  from pg_tables t
  join pg_class c on c.relname = t.tablename
  where t.schemaname = 'public'
    and (not t.rowsecurity or not c.relforcerowsecurity);
  if bad is not null then
    raise exception 'RLS not enabled/forced on: %', bad;
  end if;

  -- 2: non-system roles that bypass RLS
  select string_agg(rolname, ', ') into bad
  from pg_roles
  where rolbypassrls and rolname not like 'pg\_%' and rolname <> 'postgres';
  if bad is not null then
    raise exception 'BYPASSRLS roles present: %', bad;
  end if;

  -- 3: views without security_invoker
  select string_agg(c.relname, ', ') into bad
  from pg_class c
  join pg_namespace n on n.oid = c.relnamespace
  where c.relkind = 'v' and n.nspname = 'public'
    and coalesce(c.reloptions::text, '') not like '%security_invoker=true%';
  if bad is not null then
    raise exception 'Views without security_invoker: %', bad;
  end if;
end $$;
Enter fullscreen mode Exit fullscreen mode

Adjust the allowlists to taste (maybe your migration role legitimately keeps BYPASSRLS; exclude it explicitly so the exception is a decision, not an accident). On a normal-sized schema this runs in tens of milliseconds. It costs nothing and converts all three silent fails into a crash at boot, which is where you want them.

This class of bug is also why I care about local dev running real Postgres with the same defaults as prod. It is one of the reasons we built Tinbase: a single-binary, MIT-licensed, Supabase-compatible local runtime on actual Postgres 17, no Docker, so checks like the one above behave identically on your laptop and in prod, security_invoker included.

The takeaway

RLS is enforcement only when three things are true: the table has it enabled and forced, the connecting role is subject to it, and every view over it runs as the invoker. Each condition fails silently on its own. Check all three mechanically, at boot, every time.

Have you been bitten by a policy that turned out to be decorative? What did the tripwire look like once you built one? Comments are open.

Top comments (0)