---
name: rls-security-gate
description: 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).
license: MIT
metadata:
  author: TechTide AI (Alex Cinovoj)
  provenance: original
  category: Quality & Security
---

# 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.
