Skip to main content
AC
Quality & Security5.6 KBMIT licensed

rls-security-gate

Original, written for TechTide client work

Pre-ship security gate for Supabase-backed apps enforcing RLS on every table, client/server secret zoning, and fail-closed webhooks. Use before shipping any change that adds or modifies tables, policies, server functions, secrets, or webhook handlers. Not for authoring migrations (use supabase-schema-discipline), money-specific review (use money-path-halt-go first), or full-codebase sweeps (use supabase-codebase-audit).

  • rls
  • security
  • gate

SKILL.md

RLS & Security Gate

Fail closed. If a check is ambiguous, treat it as failing and ask the user. This gate runs on the diff about to ship, not on intentions.

Zone model

  • Blue zone (client-safe): code that ships to the browser, typically src/. Allowed env: the Supabase URL, the publishable key, other VITE_* publishable values. Allowed client: the standard browser Supabase client.
  • Red zone (server-only): server function handler bodies, *.server.ts files, server route handlers. Allowed env: SUPABASE_SERVICE_ROLE_KEY, webhook secrets, provider secret keys. Allowed client: the admin client or an auth-enforcing middleware client.

Crossing the boundary is a ship block. A red-zone import reachable from a client bundle leaks secrets to every visitor.

Pre-ship checks

1. RLS on every public table

  • ALTER TABLE public.<t> ENABLE ROW LEVEL SECURITY; appears in the same migration that created the table.
  • At least one policy per access mode the app actually uses (SELECT, INSERT, UPDATE, DELETE).
  • GRANTs in the same migration. Authoring rules live in supabase-schema-discipline.
  • Role checks go through a has_role(auth.uid(), '<role>') helper against a separate roles table, never a column on the table being policed (recursion).

2. Secret zoning

  • Grep the diff for SERVICE_ROLE, SECRET, and provider key names (STRIPE_SECRET, PADDLE_API_KEY, and similar). Every hit must sit in a red-zone file.
  • process.env.* reads only in the red zone. import.meta.env.VITE_* only in blue-zone or SSR-safe modules.
  • No secret read at module scope of any file reachable from a route. Read secrets inside the handler.
  • Server-client imports from shared files happen via await import(...) inside the handler, not top-level.

3. Webhooks fail closed

For any public webhook handler:

  • Reads the raw body (await request.text()) before parsing.
  • Verifies the provider signature with timingSafeEqual or the provider SDK before any DB write, queue push, or external call.
  • Returns 401 on missing or invalid signature, without logging the body.
  • Validates the payload with a schema (Zod or equivalent) before processing.
  • Wraps side effects in persisted idempotency. No "ack first, process later" without persisted dedup.

4. Server function auth

  • User-scoped reads and writes go through auth-requiring middleware.
  • Admin-bypass operations carry a documented justification: cron, verified webhook, or admin-role gate.
  • Protected server functions are never called from a public route loader.

5. Tables that must never leak

PII, auth tokens, secret material, payment instruments, internal ledgers: no TO anon GRANT, no anon-readable policy. If a public read is genuinely needed, expose a narrow server function with explicit column projection.

Good vs bad

Good: a profiles table ships with RLS enabled, a SELECT policy on auth.uid() = user_id, and the admin dashboard reads it through a server function gated by has_role.

Bad: "RLS is enabled" with a single USING (true) SELECT policy "so the app works, we'll tighten it later." That is anonymous full-table read access with extra steps. Enabled RLS with a permissive policy is not protection.

Verification

Do this: for each new or altered table, run a query as an unauthenticated client and as a non-owner authenticated user. Expect zero rows or a permission error in both cases, unless a policy intentionally allows the read. If either returns data, the gate fails: name the table and policy, write the fix, do not ship. Then grep the built client bundle for SERVICE_ROLE. Expect zero hits. Any hit is a leak, fix the import graph.

Completion checklist

  • [ ] Every new or altered table verified with the two-client query test
  • [ ] Diff grepped for secrets, all hits in red-zone files
  • [ ] Webhook handlers verify signatures before any side effect
  • [ ] Server function auth model stated for each new function
  • [ ] No anon path to PII, tokens, instruments, or ledgers
  • [ ] Result logged: "rls-security-gate passed: <one-line summary>"

Any box unchecked: not done. Fix or say so.

Footguns

  • Policy references a column on its own table for role checks: infinite recursion or trivially spoofable. Use a separate roles table via a has_role helper.
  • Secret read at module scope of a shared file: the bundler pulls it into the client build even though "the client never calls it". Move the read inside the handler.
  • Webhook verified after JSON.parse: re-serialization breaks byte-exact signatures intermittently. Raw body first.
  • Silencing a security finding to get green: never auto-ignore a finding without explicit user approval, and record the approval.

Red Flags: stop if you think or read any of these

  • "RLS is on, that's enough"
  • "It's just an internal table"
  • "We'll add policies after launch"
  • "The anon key can't do anything anyway"
  • "Nobody knows this webhook URL"
  • "The check is probably fine, it compiled"

Each one means: run the failing check for real. No table, deadline, or "internal" label exempts a diff from this gate, and an ambiguous check is a failed check.

More in Quality & Security

All skills