feat: supabase db references (#17)

* feat: supabase db references

* refactor: move database references to db subdirectory

Move all database reference files to references/db/ to organize
by product area and take advantage of the new subdirectory support
in the build system.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Pedro Rodrigues
2026-01-27 22:05:55 +00:00
committed by Pedro Rodrigues
parent 61e98ded97
commit cca41f7354
21 changed files with 1811 additions and 4 deletions

View File

@@ -22,6 +22,51 @@ supabase/
2. Browse `references/` for detailed documentation on specific topics
3. Reference files are loaded on-demand - read only what you need
## Reference Categories
| Priority | Category | Impact | Prefix |
|----------|----------|--------|--------|
| 1 | Row Level Security | CRITICAL | `rls-` |
| 2 | Connection Pooling | CRITICAL | `conn-` |
| 3 | Schema Design | HIGH | `schema-` |
| 4 | Migrations | HIGH | `migrations-` |
| 5 | Performance | CRITICAL | `perf-` |
| 6 | Security | CRITICAL | `security-` |
Reference files are named `{prefix}-{topic}.md` (e.g., `query-missing-indexes.md`).
## Available References
**Connection Pooling** (`conn-`):
- `references/conn-pooling.md`
**Migrations** (`migrations-`):
- `references/migrations-diff.md`
- `references/migrations-idempotent.md`
- `references/migrations-testing.md`
**Performance** (`perf-`):
- `references/perf-indexes.md`
- `references/perf-query-optimization.md`
**Row Level Security** (`rls-`):
- `references/rls-common-mistakes.md`
- `references/rls-mandatory.md`
- `references/rls-performance.md`
- `references/rls-policy-types.md`
- `references/rls-views.md`
**Schema Design** (`schema-`):
- `references/schema-auth-fk.md`
- `references/schema-extensions.md`
- `references/schema-jsonb.md`
- `references/schema-realtime.md`
- `references/schema-timestamps.md`
**Security** (`security-`):
- `references/security-functions.md`
- `references/security-service-role.md`
---
*0 reference files across 0 categories*
*18 reference files across 6 categories*

View File

@@ -48,8 +48,14 @@ Reference the appropriate resource file based on the user's needs:
### Database
| Area | Resource | When to Use |
| -------- | ------------------------ | -------------------------------------- |
| ------------------ | ------------------------------- | ---------------------------------------------- |
| Database | `references/database.md` | Postgres queries, migrations, modeling |
| RLS Security | `references/db/rls-*.md` | Row Level Security policies, common mistakes |
| Connection Pooling | `references/db/conn-pooling.md` | Transaction vs Session mode, port 6543 vs 5432 |
| Schema Design | `references/db/schema-*.md` | auth.users FKs, timestamps, JSONB, extensions |
| Migrations | `references/db/migrations-*.md` | CLI workflows, idempotent patterns, db diff |
| Performance | `references/db/perf-*.md` | Indexes (BRIN, GIN), query optimization |
| Security | `references/db/security-*.md` | Service role key, security_definer functions |
### Storage & Media

View File

@@ -0,0 +1,36 @@
# Section Definitions
Reference files are grouped by prefix. Claude loads specific files based on user
queries.
---
## 1. Row Level Security (rls)
**Impact:** CRITICAL
**Description:** RLS policies, common mistakes, performance optimizations, and security patterns specific to Supabase's auth.uid() integration.
## 2. Connection Pooling (conn)
**Impact:** CRITICAL
**Description:** Supabase-specific connection pooling with Supavisor. Transaction mode (port 6543) vs Session mode (port 5432).
## 3. Schema Design (schema)
**Impact:** HIGH
**Description:** Supabase-specific schema patterns including auth.users foreign keys, timestamptz, JSONB usage, extensions, and Realtime.
## 4. Migrations (migrations)
**Impact:** HIGH
**Description:** Migration workflows using Supabase CLI, idempotent patterns, supabase db diff, and local testing strategies.
## 5. Performance (perf)
**Impact:** CRITICAL
**Description:** Index strategies (BRIN, GIN, partial), query optimization for PostgREST, and Supabase-specific performance patterns.
## 6. Security (security)
**Impact:** CRITICAL
**Description:** Service role key handling, security definer functions in private schemas, and Supabase-specific security patterns.

View File

@@ -0,0 +1,106 @@
---
title: Use Correct Connection Pooling Mode
impact: CRITICAL
impactDescription: Prevents connection exhaustion and enables 10-100x scalability
tags: connection-pooling, supavisor, transaction-mode, session-mode
---
## Use Correct Connection Pooling Mode
Supabase provides Supavisor for connection pooling. Choose the right mode based
on your application type.
## Transaction Mode (Port 6543)
Best for: Serverless functions, edge computing, stateless APIs.
```bash
## Transaction mode connection string
postgres://postgres.[ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres
```
**Limitations:**
- No prepared statements
- No SET commands
- No LISTEN/NOTIFY
- No temp tables
```javascript
// Prisma - disable prepared statements
const prisma = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL + "?pgbouncer=true",
},
},
});
```
## Session Mode (Port 5432)
Best for: Long-running servers, apps needing prepared statements.
```bash
## Session mode (via pooler for IPv4)
postgres://postgres.[ref]:[password]@aws-0-[region].pooler.supabase.com:5432/postgres
```
## Direct Connection (Port 5432)
Best for: Migrations, admin tasks, persistent servers.
```bash
## Direct connection (IPv6 only unless IPv4 add-on enabled)
postgres://postgres.[ref]:[password]@db.[ref].supabase.co:5432/postgres
```
## Common Mistakes
**Incorrect:**
```javascript
// Serverless with session mode - exhausts connections
const pool = new Pool({
connectionString: "...pooler.supabase.com:5432/postgres",
max: 20, // Too many connections per instance!
});
```
**Correct:**
```javascript
// Serverless with transaction mode
const pool = new Pool({
connectionString: "...pooler.supabase.com:6543/postgres",
max: 1, // Single connection per serverless instance
});
```
**Incorrect:**
```bash
## Transaction mode with prepared statements
DATABASE_URL="...pooler.supabase.com:6543/postgres"
## Error: prepared statement already exists
```
**Correct:**
```bash
## Add pgbouncer=true to disable prepared statements
DATABASE_URL="...pooler.supabase.com:6543/postgres?pgbouncer=true"
```
## Connection Limits by Compute Size
| Compute | Direct Connections | Pooler Clients |
| ------- | ------------------ | -------------- |
| Nano | 60 | 200 |
| Small | 90 | 400 |
| Medium | 120 | 600 |
| Large | 160 | 800 |
## Related
- [Docs](https://supabase.com/docs/guides/database/connecting-to-postgres)

View File

@@ -0,0 +1,97 @@
---
title: Use supabase db diff for Dashboard Changes
impact: HIGH
impactDescription: Captures manual changes into version-controlled migrations
tags: migrations, supabase-cli, db-diff, dashboard
---
## Use supabase db diff for Dashboard Changes
When making schema changes via Dashboard, use `supabase db diff` to generate
migration files for version control.
**Incorrect:**
```sql
-- Making changes in Dashboard without capturing them
-- Changes exist in remote but not in version control
-- Team members can't reproduce the database state
```
**Correct:**
```bash
# After making Dashboard changes, generate migration
supabase db diff -f add_profiles_table
# Review and test
supabase db reset
# Commit to version control
git add supabase/migrations/
git commit -m "Add profiles table migration"
```
## Workflow
1. Make changes in Supabase Dashboard (create tables, add columns, etc.)
2. Generate migration from diff:
```bash
supabase db diff -f add_profiles_table
```
3. Review generated migration in `supabase/migrations/`
4. Test locally:
```bash
supabase db reset
```
5. Commit migration to version control
## Diff Against Local Database
```bash
# Start local Supabase
supabase start
# Make changes via Dashboard or SQL
# Generate diff
supabase db diff -f my_changes
```
## Diff Against Remote Database
```bash
# Link to remote project
supabase link --project-ref your-project-ref
# Pull remote schema and generate diff
supabase db diff --linked -f sync_remote_changes
```
## What diff Captures
- Tables and columns
- Indexes
- Constraints
- Functions and triggers
- RLS policies
- Extensions
## What diff Does NOT Capture
- DML (INSERT, UPDATE, DELETE)
- View ownership changes
- Materialized views
- Partitions
- Comments
For these, write manual migrations.
## Related
- [migrations-idempotent.md](migrations-idempotent.md)
- [migrations-testing.md](migrations-testing.md)
- [Docs](https://supabase.com/docs/guides/deployment/database-migrations)

View File

@@ -0,0 +1,90 @@
---
title: Write Idempotent Migrations
impact: HIGH
impactDescription: Safe to run multiple times, prevents migration failures
tags: migrations, idempotent, supabase-cli
---
## Write Idempotent Migrations
Migrations should be safe to run multiple times without errors. Use
`IF NOT EXISTS` and `IF EXISTS` clauses.
**Incorrect:**
```sql
-- Fails on second run: "relation already exists"
create table users (
id uuid primary key,
email text not null
);
create index idx_users_email on users(email);
```
**Correct:**
```sql
-- Safe to run multiple times
create table if not exists users (
id uuid primary key,
email text not null
);
create index if not exists idx_users_email on users(email);
```
## Idempotent Column Additions
```sql
-- Add column only if it doesn't exist
do $$
begin
if not exists (
select 1 from information_schema.columns
where table_name = 'users' and column_name = 'phone'
) then
alter table users add column phone text;
end if;
end $$;
```
## Idempotent Drops
```sql
-- Safe drops
drop table if exists old_table;
drop index if exists old_index;
drop function if exists old_function();
```
## Idempotent Policies
```sql
-- Drop and recreate to update policy
drop policy if exists "Users see own data" on users;
create policy "Users see own data" on users
for select to authenticated
using ((select auth.uid()) = id);
```
## Migration File Naming
Migrations in `supabase/migrations/` are named with timestamps:
```
20240315120000_create_users.sql
20240315130000_add_profiles.sql
```
Create new migration:
```bash
supabase migration new create_users
```
## Related
- [migrations-testing.md](migrations-testing.md)
- [Docs](https://supabase.com/docs/guides/deployment/database-migrations)

View File

@@ -0,0 +1,116 @@
---
title: Test Migrations with supabase db reset
impact: MEDIUM-HIGH
impactDescription: Catch migration errors before production deployment
tags: migrations, testing, supabase-cli, local-development
---
## Test Migrations with supabase db reset
Always test migrations locally before deploying to production. Use
`supabase db reset` to verify migrations run cleanly from scratch.
**Incorrect:**
```bash
# Deploying directly without testing
supabase db push # Migration fails in production!
```
**Correct:**
```bash
# Test migrations locally first
supabase db reset # Runs all migrations from scratch
# Verify success, then deploy
supabase db push
```
## Testing Workflow
```bash
# Start local Supabase
supabase start
# Reset database and run all migrations
supabase db reset
# Verify tables and data
supabase inspect db table-sizes
```
## What db reset Does
1. Drops the local database
2. Creates a fresh database
3. Runs all migrations in order
4. Runs `supabase/seed.sql` if present
## Seed Data for Testing
Create `supabase/seed.sql` for test data:
```sql
-- supabase/seed.sql
-- Runs after migrations on db reset
-- Use ON CONFLICT for idempotency
insert into categories (name)
values ('Action'), ('Comedy'), ('Drama')
on conflict (name) do nothing;
-- Test users (only in local development!)
insert into profiles (id, username)
values ('00000000-0000-0000-0000-000000000001', 'testuser')
on conflict (id) do nothing;
```
## Test Specific Migration
```bash
# Apply single pending migration
supabase migration up
# Check migration status
supabase migration list
```
## Repair Failed Migration
If a migration partially fails:
```bash
# Fix the migration file
# Then repair the migration history
supabase migration repair --status applied 20240315120000
```
## Inspect Database State
```bash
# View tables
supabase inspect db table-sizes
# View indexes
supabase inspect db index-usage
# View cache hit rate
supabase inspect db cache-hit
```
## CI/CD Integration
```yaml
# GitHub Actions example
- name: Test migrations
run: |
supabase start
supabase db reset
supabase test db # Run pgTAP tests
```
## Related
- [migrations-idempotent.md](migrations-idempotent.md)
- [Docs](https://supabase.com/docs/guides/local-development/overview)

View File

@@ -0,0 +1,114 @@
---
title: Choose the Right Index Type
impact: CRITICAL
impactDescription: 10-1000x query performance improvements with proper indexing
tags: indexes, performance, btree, brin, gin, partial
---
## Choose the Right Index Type
Supabase uses PostgreSQL indexes. Choose the right type for your query patterns.
## B-Tree (Default)
Best for: Equality, range queries, sorting.
```sql
-- Equality and range queries
create index idx_users_email on users(email);
create index idx_orders_created on orders(created_at);
-- Composite index for multi-column queries
create index idx_orders_user_status on orders(user_id, status);
```
## BRIN (Block Range Index)
Best for: Large tables with naturally ordered data (timestamps, sequential IDs).
10x+ smaller than B-tree.
```sql
-- Perfect for append-only timestamp columns
create index idx_logs_created on logs using brin(created_at);
create index idx_events_id on events using brin(id);
```
**When to use:** Tables with millions of rows where data is inserted in order.
## GIN (Generalized Inverted Index)
Best for: JSONB, arrays, full-text search.
```sql
-- JSONB containment queries
create index idx_users_metadata on users using gin(metadata);
-- Full-text search
create index idx_posts_search on posts using gin(to_tsvector('english', title || ' ' || content));
-- Array containment
create index idx_tags on posts using gin(tags);
```
## Partial Index
Best for: Queries that filter on specific values.
```sql
-- Only index active users (smaller, faster)
create index idx_active_users on users(email)
where status = 'active';
-- Only index unprocessed orders
create index idx_pending_orders on orders(created_at)
where processed = false;
```
**Requirement:** Query WHERE clause must match index condition.
## Common Mistakes
**Incorrect:**
```sql
-- Over-indexing: slows writes, wastes space
create index idx_users_1 on users(email);
create index idx_users_2 on users(email, name);
create index idx_users_3 on users(name, email);
create index idx_users_4 on users(name);
```
**Correct:**
```sql
-- Minimal indexes based on actual queries
create index idx_users_email on users(email); -- For login
create index idx_users_name on users(name); -- For search
```
## Verify Index Usage
```sql
-- Check if query uses index
explain analyze
select * from users where email = 'test@example.com';
-- Find unused indexes
select * from pg_stat_user_indexes
where idx_scan = 0 and indexrelname not like '%_pkey';
```
## Concurrently Create Indexes
For production tables, avoid locking:
```sql
-- Doesn't block writes
create index concurrently idx_users_email on users(email);
```
## Related
- [rls-performance.md](rls-performance.md)
- [schema-jsonb.md](schema-jsonb.md)
- [Docs](https://supabase.com/docs/guides/database/postgres/indexes)

View File

@@ -0,0 +1,149 @@
---
title: Optimize Queries for PostgREST
impact: HIGH
impactDescription: Faster API responses and reduced database load
tags: postgrest, queries, performance, optimization, supabase-js
---
## Optimize Queries for PostgREST
Supabase uses PostgREST to generate REST APIs. Optimize queries for better
performance.
## Select Only Needed Columns
**Incorrect:**
```javascript
// Fetches all columns including large text/blobs
const { data } = await supabase.from("posts").select("*");
```
**Correct:**
```javascript
// Only fetch needed columns
const { data } = await supabase.from("posts").select("id, title, author_id");
```
## Use Explicit Filters
Explicit filters help the query planner, even with RLS.
**Incorrect:**
```javascript
// Relies only on RLS - query planner has less info
const { data } = await supabase.from("posts").select("*");
```
**Correct:**
```javascript
// Explicit filter improves query plan
const { data } = await supabase
.from("posts")
.select("*")
.eq("author_id", userId);
```
## Always Paginate
**Incorrect:**
```javascript
// Could return thousands of rows
const { data } = await supabase.from("posts").select("*");
```
**Correct:**
```javascript
// Paginate results
const { data } = await supabase
.from("posts")
.select("*")
.range(0, 19) // First 20 rows
.order("created_at", { ascending: false });
```
## Efficient Joins
**Incorrect:**
```javascript
// N+1: One query per post for author
const { data: posts } = await supabase.from("posts").select("*");
for (const post of posts) {
const { data: author } = await supabase
.from("users")
.select("*")
.eq("id", post.author_id)
.single();
}
```
**Correct:**
```javascript
// Single query with embedded join
const { data } = await supabase.from("posts").select(`
id,
title,
author:users (
id,
name,
avatar_url
)
`);
```
## Use count Option Efficiently
**Incorrect:**
```javascript
// Counts ALL rows (slow on large tables)
const { count } = await supabase
.from("posts")
.select("*", { count: "exact", head: true });
```
**Correct:**
```javascript
// Estimated count (fast)
const { count } = await supabase
.from("posts")
.select("*", { count: "estimated", head: true });
// Or planned count (uses query planner estimate)
const { count } = await supabase
.from("posts")
.select("*", { count: "planned", head: true });
```
## Debug Query Performance
```javascript
// Get query execution plan
const { data } = await supabase
.from("posts")
.select("*")
.eq("author_id", userId)
.explain({ analyze: true, verbose: true });
console.log(data); // Shows execution plan
```
Enable explain in database:
```sql
alter role authenticator set pgrst.db_plan_enabled to true;
notify pgrst, 'reload config';
```
## Related
- [perf-indexes.md](perf-indexes.md)
- [Docs](https://supabase.com/docs/guides/database/query-optimization)

View File

@@ -0,0 +1,97 @@
---
title: Avoid Common RLS Policy Mistakes
impact: CRITICAL
impactDescription: Prevents security vulnerabilities and unintended data exposure
tags: rls, security, auth.uid, policies, common-mistakes
---
## Avoid Common RLS Policy Mistakes
## 1. Missing TO Clause
Without `TO`, policies apply to all roles including `anon`.
**Incorrect:**
```sql
-- Runs for both anon and authenticated users
create policy "Users see own data" on profiles
using (auth.uid() = user_id);
```
**Correct:**
```sql
-- Only runs for authenticated users
create policy "Users see own data" on profiles
to authenticated
using (auth.uid() = user_id);
```
## 2. Using user_metadata for Authorization
Users can modify their own `user_metadata`. Use `app_metadata` instead.
**Incorrect:**
```sql
-- DANGEROUS: users can set their own role!
using ((auth.jwt() -> 'user_metadata' ->> 'role') = 'admin')
```
**Correct:**
```sql
-- app_metadata cannot be modified by users
using ((auth.jwt() -> 'app_metadata' ->> 'role') = 'admin')
```
## 3. Not Checking NULL auth.uid()
For unauthenticated users, `auth.uid()` returns NULL.
**Incorrect:**
```sql
-- NULL = NULL is NULL (not true), but confusing behavior
using (auth.uid() = user_id)
```
**Correct:**
```sql
-- Explicit NULL check
using (auth.uid() is not null and auth.uid() = user_id)
```
## 4. Missing SELECT Policy for UPDATE
UPDATE operations require a SELECT policy to find rows to update.
**Incorrect:**
```sql
-- UPDATE silently fails - no rows found
create policy "Users can update" on profiles
for update to authenticated
using (auth.uid() = user_id);
```
**Correct:**
```sql
-- Need both SELECT and UPDATE policies
create policy "Users can view" on profiles
for select to authenticated
using (auth.uid() = user_id);
create policy "Users can update" on profiles
for update to authenticated
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
```
## Related
- [rls-mandatory.md](rls-mandatory.md)
- [Docs](https://supabase.com/docs/guides/database/postgres/row-level-security)

View File

@@ -0,0 +1,50 @@
---
title: Enable RLS on All Exposed Schemas
impact: CRITICAL
impactDescription: Prevents unauthorized data access at the database level
tags: rls, security, auth, policies
---
## Enable RLS on All Exposed Schemas
RLS must be enabled on every table in exposed schemas (default: `public`). Without
RLS, any user with the anon key can read and write all data.
**Incorrect:**
```sql
-- Table without RLS - anyone can read/write everything
create table profiles (
id uuid primary key,
user_id uuid,
bio text
);
```
**Correct:**
```sql
create table profiles (
id uuid primary key,
user_id uuid references auth.users(id) on delete cascade,
bio text
);
-- Enable RLS
alter table profiles enable row level security;
-- Create policy
create policy "Users can view own profile"
on profiles for select
to authenticated
using (auth.uid() = user_id);
```
Tables created via Dashboard have RLS enabled by default. Tables created via SQL
require manual enablement. Supabase sends daily warnings for tables without RLS.
**Note:** Service role key bypasses ALL RLS policies. Never expose it to browsers.
## Related
- [Docs](https://supabase.com/docs/guides/database/postgres/row-level-security)

View File

@@ -0,0 +1,108 @@
---
title: Optimize RLS Policy Performance
impact: CRITICAL
impactDescription: Achieve 100x-99,000x query performance improvements
tags: rls, performance, optimization, indexes, auth.uid
---
## Optimize RLS Policy Performance
RLS policies run on every row access. Unoptimized policies cause severe
performance degradation.
## 1. Wrap auth.uid() in SELECT (94-99% improvement)
**Incorrect:**
```sql
-- auth.uid() called for every row
create policy "Users see own data" on profiles
to authenticated
using (auth.uid() = user_id);
```
**Correct:**
```sql
-- Cached once per statement via initPlan
create policy "Users see own data" on profiles
to authenticated
using ((select auth.uid()) = user_id);
```
## 2. Add Indexes on Policy Columns (99% improvement)
**Incorrect:**
```sql
-- Full table scan for every query
create policy "Users see own data" on profiles
using ((select auth.uid()) = user_id);
-- No index on user_id
```
**Correct:**
```sql
create policy "Users see own data" on profiles
using ((select auth.uid()) = user_id);
-- Add index on filtered column
create index idx_profiles_user_id on profiles(user_id);
```
## 3. Use Explicit Filters in Queries (94% improvement)
**Incorrect:**
```javascript
// Relies only on implicit RLS filter
const { data } = await supabase.from("profiles").select("*");
```
**Correct:**
```javascript
// Add explicit filter - helps query planner
const { data } = await supabase
.from("profiles")
.select("*")
.eq("user_id", userId);
```
## 4. Use Security Definer Functions for Joins
**Incorrect:**
```sql
-- Join in policy - executed per row
using (
user_id in (
select user_id from team_members
where team_id = teams.id -- joins!
)
)
```
**Correct:**
```sql
-- Function in private schema
create function private.user_team_ids()
returns setof uuid
language sql
security definer
stable
as $$
select team_id from team_members
where user_id = (select auth.uid())
$$;
-- Policy uses cached function result
using (team_id in (select private.user_team_ids()))
```
## Related
- [security-functions.md](security-functions.md)
- [Supabase RLS Performance Guide](https://github.com/orgs/supabase/discussions/14576)

View File

@@ -0,0 +1,81 @@
---
title: Use RESTRICTIVE vs PERMISSIVE Policies
impact: MEDIUM-HIGH
impactDescription: Controls policy combination logic to prevent unintended access
tags: rls, policies, permissive, restrictive
---
## Use RESTRICTIVE vs PERMISSIVE Policies
Supabase RLS supports two policy types with different combination logic.
## PERMISSIVE (Default)
Multiple permissive policies combine with OR logic. If ANY policy passes, access
is granted.
```sql
-- User can access if they own it OR are an admin
create policy "Owner access" on documents
for select to authenticated
using (owner_id = (select auth.uid()));
create policy "Admin access" on documents
for select to authenticated
using ((select auth.jwt() -> 'app_metadata' ->> 'role') = 'admin');
```
## RESTRICTIVE
Restrictive policies combine with AND logic. ALL restrictive policies must pass.
**Use Case: Enforce MFA for sensitive operations**
```sql
-- Base access policy (permissive)
create policy "Users can view own data" on sensitive_data
for select to authenticated
using (user_id = (select auth.uid()));
-- MFA requirement (restrictive) - MUST also pass
create policy "Require MFA" on sensitive_data
as restrictive
for select to authenticated
using ((select auth.jwt() ->> 'aal') = 'aal2');
```
**Use Case: Block OAuth client access**
```sql
-- Allow direct session access
create policy "Direct access only" on payment_methods
as restrictive
for all to authenticated
using ((select auth.jwt() ->> 'client_id') is null);
```
## Common Mistake
**Incorrect:**
```sql
-- Intended as additional requirement, but PERMISSIVE means OR
create policy "Require MFA" on sensitive_data
for select to authenticated
using ((select auth.jwt() ->> 'aal') = 'aal2');
```
**Correct:**
```sql
-- AS RESTRICTIVE makes it an AND requirement
create policy "Require MFA" on sensitive_data
as restrictive
for select to authenticated
using ((select auth.jwt() ->> 'aal') = 'aal2');
```
## Related
- [rls-common-mistakes.md](rls-common-mistakes.md)
- [Docs](https://supabase.com/docs/guides/database/postgres/row-level-security)

View File

@@ -0,0 +1,65 @@
---
title: Use security_invoker for Views with RLS
impact: HIGH
impactDescription: Ensures views respect RLS policies instead of bypassing them
tags: rls, views, security_invoker, security
---
## Use security_invoker for Views with RLS
By default, views run as the view owner (security definer), bypassing RLS on
underlying tables.
**Incorrect:**
```sql
-- View bypasses RLS - exposes all data!
create view public_profiles as
select id, username, avatar_url
from profiles;
```
**Correct (Postgres 15+):**
```sql
-- View respects RLS of querying user
create view public_profiles
with (security_invoker = true)
as
select id, username, avatar_url
from profiles;
```
**Correct (Older Postgres):**
```sql
-- Option 1: Revoke direct access, create RLS on view
revoke all on public_profiles from anon, authenticated;
-- Option 2: Create view in unexposed schema
create schema private;
create view private.profiles_view as
select * from profiles;
```
## When to Use security_definer
Use `security_definer = true` (default) when the view intentionally aggregates
or filters data that users shouldn't access directly:
```sql
-- Intentionally exposes limited public data
create view leaderboard as
select username, score
from profiles
order by score desc
limit 100;
-- Grant read access
grant select on leaderboard to anon;
```
## Related
- [rls-mandatory.md](rls-mandatory.md)
- [Docs](https://supabase.com/docs/guides/database/postgres/row-level-security)

View File

@@ -0,0 +1,80 @@
---
title: Add CASCADE to auth.users Foreign Keys
impact: HIGH
impactDescription: Prevents orphaned records and user deletion failures
tags: foreign-keys, auth.users, cascade, schema-design
---
## Add CASCADE to auth.users Foreign Keys
When referencing `auth.users`, always specify `ON DELETE CASCADE`. Without it,
deleting users fails with foreign key violations.
**Incorrect:**
```sql
-- User deletion fails: "foreign key violation"
create table profiles (
id uuid primary key references auth.users(id),
username text,
avatar_url text
);
```
**Correct:**
```sql
-- Profile deleted automatically when user is deleted
create table profiles (
id uuid primary key references auth.users(id) on delete cascade,
username text,
avatar_url text
);
```
## Alternative: SET NULL for Optional Relationships
Use `ON DELETE SET NULL` when the record should persist without the user:
```sql
create table comments (
id bigint primary key generated always as identity,
author_id uuid references auth.users(id) on delete set null,
content text not null,
created_at timestamptz default now()
);
-- Comment remains with author_id = NULL after user deletion
```
## Auto-Create Profile on Signup
```sql
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
insert into public.profiles (id, email, full_name)
values (
new.id,
new.email,
new.raw_user_meta_data ->> 'full_name'
);
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
```
**Important:** Use `security definer` and `set search_path = ''` for triggers on
auth.users.
## Related
- [security-functions.md](security-functions.md)
- [Docs](https://supabase.com/docs/guides/database/postgres/cascade-deletes)

View File

@@ -0,0 +1,80 @@
---
title: Install Extensions in extensions Schema
impact: MEDIUM
impactDescription: Keeps public schema clean and simplifies migrations
tags: extensions, schema-design, best-practices
---
## Install Extensions in extensions Schema
Install PostgreSQL extensions in the `extensions` schema to keep the `public`
schema clean and avoid conflicts with application tables.
**Incorrect:**
```sql
-- Installs in public schema by default
create extension pg_trgm;
create extension pgvector;
```
**Correct:**
```sql
-- Install in extensions schema
create extension if not exists pg_trgm with schema extensions;
create extension if not exists vector with schema extensions;
-- Reference with schema prefix
create index idx_name_trgm on users
using gin(name extensions.gin_trgm_ops);
```
## Common Supabase Extensions
```sql
-- Vector similarity search (AI embeddings)
create extension if not exists vector with schema extensions;
-- Scheduled jobs
create extension if not exists pg_cron with schema extensions;
-- HTTP requests from database
create extension if not exists pg_net with schema extensions;
-- Full-text search improvements
create extension if not exists pg_trgm with schema extensions;
-- Geospatial data
create extension if not exists postgis with schema extensions;
-- UUID generation (enabled by default)
create extension if not exists "uuid-ossp" with schema extensions;
```
## Check Available Extensions
```sql
-- List available extensions
select * from pg_available_extensions;
-- List installed extensions
select * from pg_extension;
```
## Using Extensions
```sql
-- pgvector example
create table documents (
id bigint primary key generated always as identity,
content text,
embedding vector(1536) -- OpenAI ada-002 dimensions
);
create index on documents using ivfflat (embedding vector_cosine_ops);
```
## Related
- [Docs](https://supabase.com/docs/guides/database/extensions)

View File

@@ -0,0 +1,95 @@
---
title: Use Structured Columns Over JSONB When Possible
impact: MEDIUM
impactDescription: Improves query performance, type safety, and data integrity
tags: jsonb, json, schema-design, performance
---
## Use Structured Columns Over JSONB When Possible
JSONB is flexible but should not replace proper schema design. Use structured
columns for known fields, JSONB for truly dynamic data.
**Incorrect:**
```sql
-- Everything in JSONB - loses type safety and performance
create table users (
id uuid primary key,
data jsonb -- contains email, name, role, etc.
);
-- Querying is verbose and slow without indexes
select data ->> 'email' from users
where data ->> 'role' = 'admin';
```
**Correct:**
```sql
-- Structured columns for known fields
create table users (
id uuid primary key,
email text not null,
name text,
role text check (role in ('admin', 'user', 'guest')),
-- JSONB only for truly flexible data
preferences jsonb default '{}'
);
-- Fast, type-safe queries
select email from users where role = 'admin';
```
## When JSONB is Appropriate
- Webhook payloads
- User-defined fields
- API responses to cache
- Rapid prototyping (migrate to columns later)
## Indexing JSONB
```sql
-- GIN index for containment queries
create index idx_users_preferences on users using gin(preferences);
-- Query using containment operator
select * from users
where preferences @> '{"theme": "dark"}';
```
## Validate JSONB with pg_jsonschema
```sql
create extension if not exists pg_jsonschema with schema extensions;
alter table users
add constraint check_preferences check (
jsonb_matches_schema(
'{
"type": "object",
"properties": {
"theme": {"type": "string", "enum": ["light", "dark"]},
"notifications": {"type": "boolean"}
}
}',
preferences
)
);
```
## Querying JSONB
```javascript
// supabase-js
const { data } = await supabase
.from("users")
.select("email, preferences->theme")
.eq("preferences->>notifications", "true");
```
## Related
- [perf-indexes.md](perf-indexes.md)
- [Docs](https://supabase.com/docs/guides/database/json)

View File

@@ -0,0 +1,91 @@
---
title: Realtime Requires Primary Keys
impact: MEDIUM-HIGH
impactDescription: Prevents Realtime subscription failures and data sync issues
tags: realtime, primary-keys, subscriptions
---
## Realtime Requires Primary Keys
Supabase Realtime uses primary keys to track row changes. Tables without primary
keys cannot be subscribed to.
**Incorrect:**
```sql
-- No primary key - Realtime subscriptions will fail
create table messages (
user_id uuid,
content text,
created_at timestamptz default now()
);
```
**Correct:**
```sql
create table messages (
id bigint primary key generated always as identity,
user_id uuid references auth.users(id) on delete cascade,
content text not null,
created_at timestamptz default now()
);
```
## Enable Realtime for a Table
**Via SQL:**
```sql
-- Add table to realtime publication
alter publication supabase_realtime add table messages;
```
**Via Dashboard:**
Database > Publications > supabase_realtime > Add table
## Realtime with RLS
RLS policies apply to Realtime subscriptions. Users only receive changes they
have access to.
```sql
-- Policy applies to realtime
create policy "Users see own messages" on messages
for select to authenticated
using (user_id = (select auth.uid()));
```
```javascript
// Subscribe with RLS filtering
const channel = supabase
.channel("messages")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "messages" },
(payload) => console.log(payload)
)
.subscribe();
```
## Performance Considerations
- Add indexes on columns used in Realtime filters
- Keep RLS policies simple for subscribed tables
- Monitor "Realtime Private Channel RLS Execution Time" in Dashboard
## Replica Identity
By default, only the primary key is sent in UPDATE/DELETE payloads. To receive
all columns:
```sql
-- Send all columns in change events (increases bandwidth)
alter table messages replica identity full;
```
## Related
- [rls-mandatory.md](rls-mandatory.md)
- [Docs](https://supabase.com/docs/guides/realtime)

View File

@@ -0,0 +1,79 @@
---
title: Always Use timestamptz Not timestamp
impact: MEDIUM-HIGH
impactDescription: Prevents timezone-related bugs and data inconsistencies
tags: timestamps, timestamptz, timezone, data-types
---
## Always Use timestamptz Not timestamp
Use `timestamptz` (timestamp with time zone) instead of `timestamp`. The latter
loses timezone information, causing bugs when users are in different timezones.
**Incorrect:**
```sql
create table events (
id bigint primary key generated always as identity,
name text not null,
-- Stores time without timezone context
created_at timestamp default now(),
starts_at timestamp
);
```
**Correct:**
```sql
create table events (
id bigint primary key generated always as identity,
name text not null,
-- Stores time in UTC, converts on retrieval
created_at timestamptz default now(),
starts_at timestamptz
);
```
## How timestamptz Works
- Stores time in UTC internally
- Converts to/from session timezone automatically
- `now()` returns current time in session timezone, stored as UTC
```sql
-- Insert with timezone
insert into events (name, starts_at)
values ('Launch', '2024-03-15 10:00:00-05'); -- EST
-- Retrieved in UTC by default in Supabase
select starts_at from events;
-- 2024-03-15 15:00:00+00
```
## Auto-Update updated_at Column
```sql
create table posts (
id bigint primary key generated always as identity,
title text not null,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- Trigger to auto-update
create or replace function update_updated_at()
returns trigger as $$
begin
new.updated_at = now();
return new;
end;
$$ language plpgsql;
create trigger posts_updated_at
before update on posts
for each row execute function update_updated_at();
```
## Related
- [Docs](https://supabase.com/docs/guides/database/tables)

View File

@@ -0,0 +1,125 @@
---
title: Use security_definer Functions in Private Schema
impact: HIGH
impactDescription: Controlled privilege escalation without exposing service role
tags: functions, security_definer, security, private-schema
---
## Use security_definer Functions in Private Schema
`security definer` functions run with the privileges of the function owner, not
the caller. Place them in a private schema to prevent direct API access.
**Incorrect:**
```sql
-- DANGEROUS: Exposed via API, can be called directly
create function public.get_all_users()
returns setof auth.users
language sql
security definer
as $$
select * from auth.users; -- Bypasses RLS!
$$;
```
**Correct:**
```sql
-- Create private schema (not exposed to API)
create schema if not exists private;
-- Function in private schema
create function private.get_all_users()
returns setof auth.users
language sql
security definer
set search_path = '' -- Prevent search_path injection
as $$
select * from auth.users;
$$;
-- Wrapper in public schema with access control
create function public.get_user_count()
returns bigint
language sql
security invoker -- Runs as caller
as $$
select count(*) from private.get_all_users()
where (select auth.jwt() -> 'app_metadata' ->> 'role') = 'admin';
$$;
```
## Common Use Cases
### 1. Admin Operations
```sql
create function private.admin_delete_user(target_user_id uuid)
returns void
language plpgsql
security definer
set search_path = ''
as $$
begin
-- Verify caller is admin
if (select auth.jwt() -> 'app_metadata' ->> 'role') != 'admin' then
raise exception 'Unauthorized';
end if;
delete from auth.users where id = target_user_id;
end;
$$;
```
### 2. Cross-User Data Access
```sql
-- Function returns team IDs the current user belongs to
create function private.user_teams()
returns setof uuid
language sql
security definer
stable
set search_path = ''
as $$
select team_id from public.team_members
where user_id = (select auth.uid());
$$;
-- RLS policy uses cached function result (no per-row join)
create policy "Team members see team data" on team_data
for select to authenticated
using (team_id in (select private.user_teams()));
```
## Security Best Practices
1. **Always set search_path = ''** - Prevents search_path injection attacks
2. **Validate caller permissions** - Don't assume caller is authorized
3. **Keep functions minimal** - Only expose necessary operations
4. **Log sensitive operations** - Audit trail for admin actions
```sql
create function private.sensitive_operation()
returns void
language plpgsql
security definer
set search_path = ''
as $$
begin
-- Log the operation
insert into audit_log (user_id, action, timestamp)
values ((select auth.uid()), 'sensitive_operation', now());
-- Perform operation
-- ...
end;
$$;
```
## Related
- [security-service-role.md](security-service-role.md)
- [rls-performance.md](rls-performance.md)
- [Docs](https://supabase.com/docs/guides/database/functions)

View File

@@ -0,0 +1,97 @@
---
title: Never Expose Service Role Key to Browser
impact: CRITICAL
impactDescription: Prevents complete database compromise and data breach
tags: service-role, security, api-keys, anon-key
---
## Never Expose Service Role Key to Browser
The service role key bypasses ALL Row Level Security. Exposing it gives complete
database access to anyone.
**Incorrect:**
```javascript
// NEVER do this - service key in frontend code!
const supabase = createClient(
"https://xxx.supabase.co",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." // service_role key
);
```
**Correct:**
```javascript
// Browser: Use anon key (respects RLS)
const supabase = createClient(
"https://xxx.supabase.co",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." // anon key
);
```
## When to Use Service Role Key
Only in server-side code that users cannot access:
```javascript
// Edge Function or backend server
import { createClient } from "@supabase/supabase-js";
const supabaseAdmin = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE_KEY // Only in secure backend
);
// Bypass RLS for admin operations
const { data } = await supabaseAdmin.from("users").select("*");
```
## Environment Variables
```bash
## .env.local (never commit to git!)
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... # Safe to expose
SUPABASE_SERVICE_ROLE_KEY=eyJ... # NEVER prefix with NEXT_PUBLIC_
```
## Identifying Keys
- **Anon key**: `role` claim is `anon`
- **Service role key**: `role` claim is `service_role`
Decode JWT at [jwt.io](https://jwt.io) to verify.
## If Service Key is Exposed
1. Immediately rotate keys in Dashboard > Settings > API
2. Review database for unauthorized changes
3. Check logs for suspicious activity
4. Update all backend services with new key
## Alternative: Security Definer Functions
Instead of service role, use security definer functions for specific elevated
operations:
```sql
-- Runs with function owner's privileges
create function admin_get_user_count()
returns bigint
language sql
security definer
set search_path = ''
as $$
select count(*) from auth.users;
$$;
-- Grant to authenticated users
grant execute on function admin_get_user_count to authenticated;
```
## Related
- [security-functions.md](security-functions.md)
- [rls-mandatory.md](rls-mandatory.md)
- [Docs](https://supabase.com/docs/guides/api/api-keys)