Linting policy SQL versus probing the live project
Two instruments dominate the practical toolkit for row-level security work, and they barely overlap. A SQL linter reads policy text — in migration files or the database's definition catalogs — and flags suspicious patterns before anything ships. A live probe requests the running API and observes what comes back after everything has shipped.
Neither subsumes the other, because each is blind to the other's failure class. Linting cannot see what a deployed application actually serves; probing cannot see code that has not shipped yet. This page describes both approaches at category level, shows what each misses, and suggests a division of labour that uses each where it is strong.
RowShield does not detect this yet. This guide gives you the catalog queries to check it yourself. The nine rules that do ship are listed on the rules index.
How static linting works
A linter parses SQL and applies pattern rules. Against policy definitions it can catch structural smells cheaply: an INSERT policy missing WITH CHECK, a USING clause that reduces to a constant, a policy scoped to a role that bypasses row-level security anyway. Because it works on text or definitions, it needs no production credentials and fits naturally beside a pull request.
The strengths follow from the model. Feedback arrives before merge, cost per run is negligible, and rules encode team standards — forbid tautological conditions, require FORCE ROW LEVEL SECURITY, require paired WITH CHECK clauses — so violations are argued with the style guide rather than with a person.
How a live probe works
A probe skips definitions entirely and speaks HTTP. Issuing GET requests against /rest/v1/<table> with the project's public anon key, it observes one of three outcomes: rows come back (exposed), an empty array returns (ambiguous — filtered and empty are indistinguishable from outside), or permission errors engage (policies did their work). Every outcome is informative, and ambiguity is recorded as reachable rather than clean.
RowShield's probe works this way and is deliberately constrained: GET, HEAD and OPTIONS only, enforced in code; loopback, RFC1918 and link-local ranges, CGNAT space and cloud-metadata endpoints refused before any socket opens, re-checked on every redirect hop. Running it against a deployed app is a single command:
# Outside-in check of a deployed surface. GET/HEAD/OPTIONS only. rowshield probe https://your-app.example.com
What each instrument cannot see
A linter evaluates text, so it cannot know whether the text it approved ever reached production, was replaced by a console edit, or survived a restore. Its truth is the repository, and repositories drift from databases whenever changes take paths around migrations.
A probe evaluates deployment, so it cannot comment on code still in review, on storage bucket configuration beyond what the surface reveals, or on internal roles and grants that never touch PostgREST. It sees the public surface and nothing behind it — by design, since needing no privileged credential is what makes it safe to run often.
Using both deliberately
The natural division: lint early, probe late, monitor always. Linting gates merges at near-zero cost; a post-deploy probe confirms the thing that shipped behaves; scheduled scans then repeat both halves — catalog checks plus probing — on a cadence, recording created, regressed and resolved transitions so drift becomes visible without anyone remembering to re-check.
RowShield automates the second and third legs of that division: nine catalog rules over pg_catalog metadata plus the anon-key probe against your deployed surface, with remediation SQL generated from your actual columns whenever anything fails. Try the free audit at rowshield.dev/audit — a project URL is the entire input, and no account stands in the way.
Frequently asked
- Can a linter prove my deployed app is safe?
- No. Linting judges definitions, not deployments — approved SQL can still be absent from production, altered later, or undone by a restore. Only exercising the running API shows what it actually serves to an anonymous caller.
- Why is an empty response not treated as safe?
- Because filtered-out and genuinely empty are indistinguishable from outside: a policy denying every row and an empty table return the same JSON array. Recording empties as reachable rather than clean keeps that ambiguity visible instead of laundering it into a pass.
- Do I need production credentials to run a probe?
- No — the probe uses only the public anon key and the project URL, which is the point. It sees exactly what an unauthenticated caller sees, which is both its safety property and the reason catalog-level scanning exists alongside it.
Check your project in about ten seconds
Paste a URL. No signup, no writes, nothing stored.
Run the free audit