supabase-rls-beamix
Beamix RLS conventions: per-user row-level security, service-role bypass patterns for Inngest jobs, war-room table deny-all policies, and audit_log write permissions. Use when writing or reviewing any
SKILL.md.claude/skills/supabase-rls-beamix/SKILL.md
GitHubSupabase RLS — Beamix
Quick reference
Every table with user data needs RLS ENABLE + at least one policy.
auth.uid()for user-owned. Service-role bypass = audit_log row.
When to use
- Writing a new migration that adds a table requiring RLS
- Reviewing whether an existing table has correct policies
- Debugging an "insufficient privilege" error from a product API route
- Writing war-room table policies for agent observability tables
When NOT to use
- For application-layer auth (that's Supabase Auth + middleware)
- For pgvector RAG query patterns (see
pgvector-rag-beamix)
Core RLS conventions
User-scoped tables (product data)
All product tables with user data use user_id = auth.uid() as the baseline policy.
-- Standard pattern for user-scoped tables
ALTER TABLE businesses ENABLE ROW LEVEL SECURITY;
CREATE POLICY "users_own_businesses"
ON businesses
FOR ALL
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
Tables that follow this pattern:
businessesscansscan_engine_resultsagent_jobscredit_poolscredit_transactionscontent_itemsrecommendationsuser_profilessubscriptionsnotification_preferences
Read-only public tables
Some tables are readable by any authenticated user but not writable (e.g., reference data):
CREATE POLICY "authenticated_read_only"
ON industries
FOR SELECT
USING (auth.role() = 'authenticated');
Service-role bypass (for Inngest and background jobs)
Inngest functions, Routines, and the Cloudflare bridge use the Supabase service-role key, which bypasses RLS entirely. This is intentional — background jobs need to write to any user's rows.
// In Inngest functions or server-side jobs — use service role
import { createClient } from '@supabase/supabase-js';
const adminClient = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // never expose this to the client
);
// This bypasses RLS — use for background job writes only
await adminClient.from('audit_log').insert({ ... });
For product API routes (user-facing), use the user's session client instead:
// In Next.js API routes — use session (respects RLS)
import { createServerClient } from '@supabase/ssr';
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: ... }
);
War-room table policies
War-room tables store agent observability data. They use RLS deny-all + service-role bypass only.
-- audit_log: deny all to authenticated users, service role only
ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;
-- No policies = deny all by default (RLS enabled, zero policies)
-- Service role bypasses this automatically
-- Adam reads via server-side /war-room route (checks session.user.email = ADAM_EMAIL)
-- This enforcement happens in Next.js middleware, not in RLS
War-room tables with deny-all policy:
audit_logaudit_log_dailyclaude_progressrag_corpusmem0_pending_writes(when implemented)
/war-room access pattern
// apps/web/src/app/war-room/page.tsx (server component)
import { headers } from 'next/headers';
import { createServerClient } from '@supabase/ssr';
export default async function WarRoomPage() {
const supabase = createServerClient(...);
const { data: { user } } = await supabase.auth.getUser();
// Hard gate: only Adam can see this page
if (user?.email !== process.env.ADAM_EMAIL) {
redirect('/dashboard');
}
// Use admin client to read war-room tables (deny-all RLS)
const adminClient = createClient(..., process.env.SUPABASE_SERVICE_ROLE_KEY!);
const { data: auditLog } = await adminClient
.from('audit_log')
.select('*')
.order('created_at', { ascending: false })
.limit(100);
return <WarRoomView data={auditLog} />;
}
plpgsql SQL Editor bug (known issue)
When running plpgsql in the Supabase SQL Editor, the editor splits on semicolons inside $$ blocks, causing 42P01 errors where DECLARE variables are treated as table lookups.
Workaround: always use LANGUAGE sql + CTEs instead of plpgsql DECLARE blocks in migrations:
-- WRONG: plpgsql with DECLARE (SQL Editor breaks this)
DO $$
DECLARE
v_user_id uuid;
BEGIN
SELECT id INTO v_user_id FROM auth.users LIMIT 1;
-- ...
END $$;
-- CORRECT: LANGUAGE sql + CTE
WITH target_user AS (
SELECT id FROM auth.users LIMIT 1
)
INSERT INTO user_profiles (user_id, created_at)
SELECT id, now() FROM target_user
ON CONFLICT (user_id) DO NOTHING;
Migration naming convention
apps/web/supabase/migrations/YYYYMMDDHHMMSS_description.sql
Example: 20260516120000_add_audit_log_indexes.sql
Each migration must be idempotent (safe to re-run). Use IF NOT EXISTS, ON CONFLICT DO NOTHING, CREATE INDEX CONCURRENTLY IF NOT EXISTS.
See also
postgresql— [[postgresql]]sql-optimization-patterns— [[sql-optimization-patterns]]gdpr-data-handling— [[gdpr-data-handling]]database-design— [[database-design]]
Anti-patterns
- Using service-role key in client-side code (exposes full DB access)
- Writing product API routes with admin client (bypasses RLS, exposes other users' data)
- Adding an RLS policy without enabling RLS first (
ALTER TABLE ... ENABLE ROW LEVEL SECURITY) - plpgsql DECLARE blocks in SQL Editor migrations (use LANGUAGE sql + CTEs)
- Mixing war-room table writes with product API routes (war-room writes = bridge + Inngest only)
- Enabling RLS without any policies (deny-all is intentional for war-room tables, but accidental deny-all on product tables breaks the app)