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, otherVITE_*publishable values. Allowed client: the standard browser Supabase client. - Red zone (server-only): server function handler bodies,
*.server.tsfiles, 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
timingSafeEqualor 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_rolehelper. - 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.