Supabase Policy Guardrails
Overview
Organizational governance for Supabase at scale: a shared RLS policy library (reusable templates for common access patterns), naming conventions (tables, columns, functions, policies), migration review process (CI checks ensuring RLS, preventing destructive operations, enforcing naming), cost alert configuration (billing thresholds and usage monitoring), and security audit scripts (scanning for exposed keys, missing RLS, overly permissive policies). All patterns use real createClient from @supabase/supabase-js and Supabase CLI commands.
Prerequisites
- Supabase project with
supabase CLI installed and linked
@supabase/supabase-js v2+ installed
- CI/CD pipeline (GitHub Actions recommended)
- Database access via
psql or Supabase SQL Editor
- Pro plan recommended for cost alerts and usage API
Step 1 — Shared RLS Policy Library and Naming Conventions
RLS Policy Templates
Create reusable RLS policy templates that teams apply to new tables. This prevents each developer from writing ad-hoc policies and ensures consistent access control.
-- supabase/migrations/00000000000000_rls_policy_library.sql
-- Shared RLS policy library — apply these templates to new tables
-- ============================================================
-- Template 1: Owner-only access (user owns the row)
-- Usage: tables with a user_id column (todos, profiles, settings)
-- ============================================================
CREATE OR REPLACE FUNCTION public.rls_owner_only(table_name text, user_column text DEFAULT 'user_id')
RETURNS void AS $$
BEGIN
EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', table_name);
EXECUTE format(
'CREATE POLICY "owner_select" ON public.%I FOR SELECT USING (%I = auth.uid())',
table_name, user_column
);
EXECUTE format(
'CREATE POLICY "owner_insert" ON public.%I FOR INSERT WITH CHECK (%I = auth.uid())',
table_name, user_column
);
EXECUTE format(
'CREATE POLICY "owner_update" ON public.%I FOR UPDATE USING (%I = auth.uid())',
table_name, user_column
);
EXECUTE format(
'CREATE POLICY "owner_delete" ON public.%I FOR DELETE USING (%I = auth.uid())',
table_name, user_column
);
END;
$$ LANGUAGE plpgsql;
-- ============================================================
-- Template 2: Organization-scoped access (user is member of org)
-- Usage: tables with org_id referencing org_members
-- ============================================================
CREATE OR REPLACE FUNCTION public.rls_org_scoped(
table_name text,
org_column text DEFAULT 'org_id',
allow_delete boolean DEFAULT false
)
RETURNS void AS $$
BEGIN
EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', table_name);
EXECUTE format(
'CREATE POLICY "org_select" ON public.%I FOR SELECT USING (
%I IN (SELECT org_id FROM public.org_members WHERE user_id = auth.uid())
)', table_name, org_column
);
EXECUTE format(
'CREATE POLICY "org_insert" ON public.%I FOR INSERT WITH CHECK (
%I IN (SELECT org_id FROM public.org_members WHERE user_id = auth.uid())
)', table_name, org_column
);
EXECUTE format(
'CREATE POLICY "org_update" ON public.%I FOR UPDATE USING (
%I IN (SELECT org_id FROM public.org_members WHERE user_id = auth.uid() AND role IN (''admin'', ''editor''))
)', table_name, org_column
);
IF allow_delete THEN
EXECUTE format(
'CREATE POLICY "org_delete" ON public.%I FOR DELETE USING (
%I IN (SELECT org_id FROM public.org_members WHERE user_id = auth.uid() AND role = ''admin'')
)', table_name, org_column
);
END IF;
END;
$$ LANGUAGE plpgsql;
-- ============================================================
-- Template 3: Public read, authenticated write
-- Usage: blog posts, product listings, public content
-- ============================================================
CREATE OR REPLACE FUNCTION public.rls_public_read_auth_write(
table_name text,
owner_column text DEFAULT 'created_by'
)
RETURNS void AS $$
BEGIN
EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', table_name);
EXECUTE format(
'CREATE POLICY "public_select" ON public.%I FOR SELECT USING (true)',
table_name
);
EXECUTE format(
'CREATE POLICY "auth_insert" ON public.%I FOR INSERT WITH CHECK (auth.uid() IS NOT NULL)',
table_name
);
EXECUTE format(
'CREATE POLICY "owner_update" ON public.%I FOR UPDATE USING (%I = auth.uid())',
table_name, owner_column
);
EXECUTE format(
'CREATE POLICY "owner_delete" ON public.%I FOR DELETE USING (%I = auth.uid())',
table_name, owner_column
);
END;
$$ LANGUAGE plpgsql;
-- Apply templates to tables:
-- SELECT public.rls_owner_only('todos');
-- SELECT public.rls_org_scoped('projects', 'org_id', true);
-- SELECT public.rls_public_read_auth_write('blog_posts', 'author_id');
Naming Conventions
-- supabase/migrations/00000000000001_naming_convention_check.sql
-- Validation function that checks naming conventions at migration time
CREATE OR REPLACE FUNCTION public.validate_naming_conventions()
RETURNS TABLE(issue text, object_name text, suggestion text) AS $$
BEGIN
-- Tables must be snake_case, plural
RETURN QUERY
SELECT
'Table name should be plural snake_case'::text,
t.tablename::text,
regexp_replace(t.tablename, '([A-Z])', '_\1', 'g')::text
FROM pg_tables t
WHERE t.schemaname = 'public'
AND (
t.tablename ~ '[A-Z]' -- contains uppercase
OR t.tablename ~ '-' -- contains hyphens
OR t.tablename !~ 's$' -- not plural (heuristic)
)
AND t.tablename NOT LIKE '\_%'; -- skip internal tables
-- Columns must be snake_case
RETURN QUERY
SELECT
'Column name should be snake_case'::text,
(c.table_name || '.' || c.column_name)::text,
regexp_replace(c.column_name, '([A-Z])', '_\1', 'g')::text
FROM information_schema.columns c
WHERE c.table_schema = 'public'
AND (c.column_name ~ '[A-Z]' OR c.column_name ~ '-');
-- Foreign key columns should end with _id
RETURN QUERY
SELECT
'Foreign key column should end with _id'::text,
(tc.table_name || '.' || kcu.column_name)::text,
(kcu.column_name || '_id')::text
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = 'public'
AND kcu.column_name NOT LIKE '%_id';
-- Boolean columns should start with is_ or has_
RETURN QUERY
SELECT
'Boolean column should start with is_ or has_'::text,
(c.table_name || '.' || c.column_name)::text,
('is_' || c.column_name)::text
FROM information_schema.columns c
WHERE c.table_schema = 'public'
AND c.data_type = 'boolean'
AND c.column_name NOT LIKE 'is_%'
AND c.column_name NOT LIKE 'has_%';
END;
$$ LANGUAGE plpgsql;
-- Run: SELECT * FROM public.validate_naming_conventions();
Naming Convention Reference
| Object |
Convention |
Example |
| Tables |
Plural snake_case |
user_profiles, order_items |
| Columns |
snake_case |
created_at, full_name |
| Foreign keys |
{referenced_table_singular}_id |
user_id, order_id |
| Booleans |
is_ or has_ prefix |
is_active, has_verified_email |
| Timestamps |
_at suffix |
created_at, updated_at, deleted_at |
| RLS policies |
{scope}_{operation} |
owner_select, org_insert |
| Functions |
verb_noun |
create_user, get_dashboard_metrics |
| Indexes |
idx_{table}_{columns} |
idx_orders_user_id_created_at |
| Migrations |
{timestamp}_{verb}_{description} |
20250322000000_create_orders_table.sql |
Step 2 — Migration Review Process with CI Checks
See CI checks, cost alerts, and security audits for GitHub Actions migration guardrails (RLS enforcement, naming checks, destructive operation blocks), pre-commit hooks, cost monitoring with Slack alerts, security audit scripts, and scheduled Edge Function audits.
Output
- Shared RLS policy library with owner-only, org-scoped, and public-read templates
- Naming convention validation function checking tables, columns, FKs, and booleans
- CI pipeline enforcing RLS, naming, and destructive operation controls
- Pre-commit hook blocking hardcoded secrets and tables without RLS
- Cost monitoring script with configurable thresholds and Slack alerting
- Security audit script detecting missing RLS, permissive policies, and missing indexes
- Scheduled Edge Function for continuous security monitoring
Error Handling
| Issue |
Cause |
Solution |
| CI RLS check fails on new table |
Migration missing ENABLE ROW LEVEL SECURITY |
Add ALTER TABLE after CREATE TABLE in same migration |
| Naming convention false positive |
Table is intentionally singular (e.g., config) |
Add to exclusion list in validation function |
| Cost alert not firing |
Missing SUPABASE_ACCESS_TOKEN |
Generate token at supabase.com/dashboard/account/tokens |
| Security audit times out |
Too many tables to scan |
Run audit on specific schemas or paginate results |
| Pre-commit blocks legitimate JWT in test |
Test fixture contains JWT-like string |
Add test file path to exclusion pattern |
| RLS template function not found |
Migration not applied |
Run supabase db reset or apply migration manually |
Examples
See CI, cost, and security reference for full examples including applying RLS templates, running security audits, and checking naming conventions.
Resources
Next Steps
For architecture patterns across different app types, see supabase-architecture-variants.
1---2name: supabase-policy-guardrails3description: Enforce organizational governance for Supabase projects: shared RLS policy library with reusable templates, table and column naming conventions, migration review process with CI checks, cost alert thresholds, and security audit scripts scanning for common misconfigurations. Use when establishing Supabase standards across teams, creating RLS policy templates, setting up migration review workflows, or auditing existing projects for security and cost issues. Trigger with phrases like "supabase governance", "supabase policy library", "supabase naming convention", "supabase migration review", "supabase cost alert", "supabase security audit", "supabase RLS template".4license: MIT5---6
7# Supabase Policy Guardrails
8
9## Overview
10
11Organizational governance for Supabase at scale: a **shared RLS policy library** (reusable templates for common access patterns), **naming conventions** (tables, columns, functions, policies), **migration review process** (CI checks ensuring RLS, preventing destructive operations, enforcing naming), **cost alert configuration** (billing thresholds and usage monitoring), and **security audit scripts** (scanning for exposed keys, missing RLS, overly permissive policies). All patterns use real `createClient` from `@supabase/supabase-js` and Supabase CLI commands.
12
13## Prerequisites
14
15- Supabase project with `supabase` CLI installed and linked
16- `@supabase/supabase-js` v2+ installed
17- CI/CD pipeline (GitHub Actions recommended)
18- Database access via `psql` or Supabase SQL Editor
19- Pro plan recommended for cost alerts and usage API
20
21## Step 1 — Shared RLS Policy Library and Naming Conventions
22
23### RLS Policy Templates
24
25Create reusable RLS policy templates that teams apply to new tables. This prevents each developer from writing ad-hoc policies and ensures consistent access control.
26
27```sql
28-- supabase/migrations/00000000000000_rls_policy_library.sql
29-- Shared RLS policy library — apply these templates to new tables
30
31-- ============================================================
32-- Template 1: Owner-only access (user owns the row)
33-- Usage: tables with a user_id column (todos, profiles, settings)
34-- ============================================================
35CREATE OR REPLACE FUNCTION public.rls_owner_only(table_name text, user_column text DEFAULT 'user_id')
36RETURNS void AS $$
37BEGIN
38 EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', table_name);
39
40 EXECUTE format(
41 'CREATE POLICY "owner_select" ON public.%I FOR SELECT USING (%I = auth.uid())',
42 table_name, user_column
43 );
44 EXECUTE format(
45 'CREATE POLICY "owner_insert" ON public.%I FOR INSERT WITH CHECK (%I = auth.uid())',
46 table_name, user_column
47 );
48 EXECUTE format(
49 'CREATE POLICY "owner_update" ON public.%I FOR UPDATE USING (%I = auth.uid())',
50 table_name, user_column
51 );
52 EXECUTE format(
53 'CREATE POLICY "owner_delete" ON public.%I FOR DELETE USING (%I = auth.uid())',
54 table_name, user_column
55 );
56END;
57$$ LANGUAGE plpgsql;
58
59-- ============================================================
60-- Template 2: Organization-scoped access (user is member of org)
61-- Usage: tables with org_id referencing org_members
62-- ============================================================
63CREATE OR REPLACE FUNCTION public.rls_org_scoped(
64 table_name text,
65 org_column text DEFAULT 'org_id',
66 allow_delete boolean DEFAULT false
67)
68RETURNS void AS $$
69BEGIN
70 EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', table_name);
71
72 EXECUTE format(
73 'CREATE POLICY "org_select" ON public.%I FOR SELECT USING (
74 %I IN (SELECT org_id FROM public.org_members WHERE user_id = auth.uid())
75 )', table_name, org_column
76 );
77 EXECUTE format(
78 'CREATE POLICY "org_insert" ON public.%I FOR INSERT WITH CHECK (
79 %I IN (SELECT org_id FROM public.org_members WHERE user_id = auth.uid())
80 )', table_name, org_column
81 );
82 EXECUTE format(
83 'CREATE POLICY "org_update" ON public.%I FOR UPDATE USING (
84 %I IN (SELECT org_id FROM public.org_members WHERE user_id = auth.uid() AND role IN (''admin'', ''editor''))
85 )', table_name, org_column
86 );
87
88 IF allow_delete THEN
89 EXECUTE format(
90 'CREATE POLICY "org_delete" ON public.%I FOR DELETE USING (
91 %I IN (SELECT org_id FROM public.org_members WHERE user_id = auth.uid() AND role = ''admin'')
92 )', table_name, org_column
93 );
94 END IF;
95END;
96$$ LANGUAGE plpgsql;
97
98-- ============================================================
99-- Template 3: Public read, authenticated write
100-- Usage: blog posts, product listings, public content
101-- ============================================================
102CREATE OR REPLACE FUNCTION public.rls_public_read_auth_write(
103 table_name text,
104 owner_column text DEFAULT 'created_by'
105)
106RETURNS void AS $$
107BEGIN
108 EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', table_name);
109
110 EXECUTE format(
111 'CREATE POLICY "public_select" ON public.%I FOR SELECT USING (true)',
112 table_name
113 );
114 EXECUTE format(
115 'CREATE POLICY "auth_insert" ON public.%I FOR INSERT WITH CHECK (auth.uid() IS NOT NULL)',
116 table_name
117 );
118 EXECUTE format(
119 'CREATE POLICY "owner_update" ON public.%I FOR UPDATE USING (%I = auth.uid())',
120 table_name, owner_column
121 );
122 EXECUTE format(
123 'CREATE POLICY "owner_delete" ON public.%I FOR DELETE USING (%I = auth.uid())',
124 table_name, owner_column
125 );
126END;
127$$ LANGUAGE plpgsql;
128
129-- Apply templates to tables:
130-- SELECT public.rls_owner_only('todos');
131-- SELECT public.rls_org_scoped('projects', 'org_id', true);
132-- SELECT public.rls_public_read_auth_write('blog_posts', 'author_id');
133```
134
135### Naming Conventions
136
137```sql
138-- supabase/migrations/00000000000001_naming_convention_check.sql
139-- Validation function that checks naming conventions at migration time
140
141CREATE OR REPLACE FUNCTION public.validate_naming_conventions()
142RETURNS TABLE(issue text, object_name text, suggestion text) AS $$
143BEGIN
144 -- Tables must be snake_case, plural
145 RETURN QUERY
146 SELECT
147 'Table name should be plural snake_case'::text,
148 t.tablename::text,
149 regexp_replace(t.tablename, '([A-Z])', '_\1', 'g')::text
150 FROM pg_tables t
151 WHERE t.schemaname = 'public'
152 AND (
153 t.tablename ~ '[A-Z]' -- contains uppercase
154 OR t.tablename ~ '-' -- contains hyphens
155 OR t.tablename !~ 's$' -- not plural (heuristic)
156 )
157 AND t.tablename NOT LIKE '\_%'; -- skip internal tables
158
159 -- Columns must be snake_case
160 RETURN QUERY
161 SELECT
162 'Column name should be snake_case'::text,
163 (c.table_name || '.' || c.column_name)::text,
164 regexp_replace(c.column_name, '([A-Z])', '_\1', 'g')::text
165 FROM information_schema.columns c
166 WHERE c.table_schema = 'public'
167 AND (c.column_name ~ '[A-Z]' OR c.column_name ~ '-');
168
169 -- Foreign key columns should end with _id
170 RETURN QUERY
171 SELECT
172 'Foreign key column should end with _id'::text,
173 (tc.table_name || '.' || kcu.column_name)::text,
174 (kcu.column_name || '_id')::text
175 FROM information_schema.table_constraints tc
176 JOIN information_schema.key_column_usage kcu
177 ON tc.constraint_name = kcu.constraint_name
178 WHERE tc.constraint_type = 'FOREIGN KEY'
179 AND tc.table_schema = 'public'
180 AND kcu.column_name NOT LIKE '%_id';
181
182 -- Boolean columns should start with is_ or has_
183 RETURN QUERY
184 SELECT
185 'Boolean column should start with is_ or has_'::text,
186 (c.table_name || '.' || c.column_name)::text,
187 ('is_' || c.column_name)::text
188 FROM information_schema.columns c
189 WHERE c.table_schema = 'public'
190 AND c.data_type = 'boolean'
191 AND c.column_name NOT LIKE 'is_%'
192 AND c.column_name NOT LIKE 'has_%';
193END;
194$$ LANGUAGE plpgsql;
195
196-- Run: SELECT * FROM public.validate_naming_conventions();
197```
198
199### Naming Convention Reference
200
201| Object | Convention | Example |
202|--------|-----------|---------|
203| Tables | Plural snake_case | `user_profiles`, `order_items` |
204| Columns | snake_case | `created_at`, `full_name` |
205| Foreign keys | `{referenced_table_singular}_id` | `user_id`, `order_id` |
206| Booleans | `is_` or `has_` prefix | `is_active`, `has_verified_email` |
207| Timestamps | `_at` suffix | `created_at`, `updated_at`, `deleted_at` |
208| RLS policies | `{scope}_{operation}` | `owner_select`, `org_insert` |
209| Functions | `verb_noun` | `create_user`, `get_dashboard_metrics` |
210| Indexes | `idx_{table}_{columns}` | `idx_orders_user_id_created_at` |
211| Migrations | `{timestamp}_{verb}_{description}` | `20250322000000_create_orders_table.sql` |
212
213## Step 2 — Migration Review Process with CI Checks
214
215See [CI checks, cost alerts, and security audits](references/ci-cost-security.md) for GitHub Actions migration guardrails (RLS enforcement, naming checks, destructive operation blocks), pre-commit hooks, cost monitoring with Slack alerts, security audit scripts, and scheduled Edge Function audits.
216
217## Output
218
219- Shared RLS policy library with owner-only, org-scoped, and public-read templates
220- Naming convention validation function checking tables, columns, FKs, and booleans
221- CI pipeline enforcing RLS, naming, and destructive operation controls
222- Pre-commit hook blocking hardcoded secrets and tables without RLS
223- Cost monitoring script with configurable thresholds and Slack alerting
224- Security audit script detecting missing RLS, permissive policies, and missing indexes
225- Scheduled Edge Function for continuous security monitoring
226
227## Error Handling
228
229| Issue | Cause | Solution |
230|-------|-------|----------|
231| CI RLS check fails on new table | Migration missing `ENABLE ROW LEVEL SECURITY` | Add `ALTER TABLE` after `CREATE TABLE` in same migration |
232| Naming convention false positive | Table is intentionally singular (e.g., `config`) | Add to exclusion list in validation function |
233| Cost alert not firing | Missing `SUPABASE_ACCESS_TOKEN` | Generate token at supabase.com/dashboard/account/tokens |
234| Security audit times out | Too many tables to scan | Run audit on specific schemas or paginate results |
235| Pre-commit blocks legitimate JWT in test | Test fixture contains JWT-like string | Add test file path to exclusion pattern |
236| RLS template function not found | Migration not applied | Run `supabase db reset` or apply migration manually |
237
238## Examples
239
240See [CI, cost, and security reference](references/ci-cost-security.md) for full examples including applying RLS templates, running security audits, and checking naming conventions.
241
242## Resources
243
244- [Supabase Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)
245- [Supabase CLI Migrations](https://supabase.com/docs/guides/cli/managing-environments)
246- [Supabase Management API](https://supabase.com/docs/reference/api/introduction)
247- [Supabase Pricing](https://supabase.com/pricing)
248- [PostgreSQL Naming Conventions](https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS)
249
250## Next Steps
251
252For architecture patterns across different app types, see `supabase-architecture-variants`.