Policies scoped to service_role are decoration
Some projects have RLS enabled, policies present, and zero protection — because every policy targets service_role exclusively. That role carries BYPASSRLS: policies do not apply to it in the first place. The arrangement bounds nothing and satisfies every naive checker.
It usually arrives by template: a generated snippet creates a policy TO service_role USING (true), the file gets applied, and the table ships with machinery that moves nothing.
Rules that check this
- criticalPolicy always evaluates to true
RLS_TAUTOLOGY
What actually happens
BYPASSRLS makes the role immune to policy evaluation, so a policy addressed solely to service_role has no audience that could be constrained by it. Meanwhile anon and authenticated — the roles your frontend actually presents — have no applicable policy and receive default-deny on reads... unless another permissive policy opens the table, in which case that other policy is doing all the work and the service_role one is theatre.
Either way the configuration lies to casual inspection: policies exist, so the table looks governed.
The attribute is visible in the catalog: pg_roles.rolbypassrls is true for service_role, and policy applicability is decided before evaluation — a policy binds a session only when its TO list intersects the roles that session presents. With the intersection empty, the policy sits stored, listed, and inert. That ordering explains why no amount of editing the expression changes anything: the expression never runs.
Seeing it in the catalog
Compare each table's policy roles against the roles that matter. Policies whose union of roles is exactly {service_role} are the decorative set:
Roles render as text arrays in pg_policies, which is why the query unnests them before aggregating: comparing sets directly catches the mixed cases eyeballing misses, including a decorative policy hiding inside an otherwise healthy table.
SELECT tablename,
array_agg(DISTINCT role::text) AS roles,
count(*) AS policy_count
FROM pg_catalog.pg_policies p
CROSS JOIN LATERAL unnest(p.roles::text[]) AS role
WHERE schemaname = 'public'
GROUP BY tablename
HAVING array_agg(DISTINCT role::text) @> ARRAY['service_role']
AND NOT array_agg(DISTINCT role::text) && ARRAY['anon','authenticated'];Writing policies that bind real callers
Target the roles that arrive with the public keys, and express ownership explicitly:
When membership checks like this one run per row, wrap the lookup so Postgres evaluates it once per statement, and confirm the referenced columns are indexed: narrowing exposure often introduces the first expensive predicate a table has ever carried.
CREATE POLICY "members_read_team_docs"
ON public.team_documents FOR SELECT TO authenticated
USING (team_id IN (
SELECT team_id FROM public.team_members
WHERE member_id = (SELECT auth.uid())
));Where RowShield lands on this
The tautology detector ignores service_role-scoped policies when deciding whether a table is protected — treating decoration as coverage would be exactly wrong — and flags the constant-true forms wherever they do bind real callers. Service-role-only setups therefore surface as unprotected tables via their siblings: whatever policy actually admits anon decides whether you get a finding.
Scans diff across runs, so unwinding a decorative setup produces a clean created/resolved trail rather than a one-off audit note.
Variations you will meet
Mixed scoping muddies audits. A table with one policy TO service_role and another TO anon is governed entirely by whichever permissive policy admits the caller — often a wide anon grant nobody remembers writing. Conversely, a policy listing public as its role applies to every role at once, including service_role harmlessly, and reads as broader than its author intended.
Template provenance varies. Some generators emit service_role policies around admin RPCs; others wrap seed helpers. The common thread is that the pattern arrives from code nobody wrote this quarter, which is why the check belongs in scheduled scanning rather than one-off reviews after something breaks.
Common false leads
Do not fix this by revoking grants from service_role — your backend legitimately needs its reach, and the disruption lands on production paths for no protective gain. Deleting the decorative policies is likewise cosmetic: observably nothing changes, because they bounded nothing. The substantive work is writing binding policies for anon and authenticated, after which the decoration can stay or go as documentation.
Another trap hides in tooling that renders a policy count per table: two policies read as double protection when in fact neither binds any caller your users present. Counts answer how many; only expressions and role lists answer whether anything is governed.
Frequently asked
- Why does the dashboard generate these policies?
- They act as explicit documentation of admin-side access intent and cost nothing at runtime, which is why the dashboard emits them. Their limitation is structural: BYPASSRLS exempts the role from evaluation entirely, so the policy can never constrain the only role it names.
- Should I delete the service_role policies?
- You may keep them once real policies exist — they cost nothing and document intent for whoever reads the schema next. What matters for safety is that at least one binding policy covers anon or authenticated for every command your application performs on the table.
- Does the probe verify this specific trap?
- Indirectly but decisively: the probe asks PostgREST what the anon key can actually read, and a decorative configuration answers immediately with whatever the real permissive policy admits — often everything. Policy counts never enter the question.
Check your project in about ten seconds
Paste a URL. No signup, no writes, nothing stored.
Run the free audit