# Supabase Row Level Security Standard

> **Scope:** nextjs-supabase
> **Layer:** 2 (on keyword)
> **Keywords:** supabase, rls, policy, security, row level security
> **Load When:** supabase rls keywords detected

Stack: Next.js 15 + Supabase + .NET Backend

## Core Rules

- ALWAYS enable RLS on every table: `ALTER TABLE t ENABLE ROW LEVEL SECURITY`
- NEVER rely solely on application-level filtering — RLS is the security boundary
- `service_role` key bypasses ALL RLS — use only on trusted backend
- ALWAYS create at least one policy after enabling RLS — otherwise no rows are accessible
- ALWAYS add indexes on columns used in RLS policies

## Policy Syntax

### USING vs WITH CHECK

| Clause | Applies To | Purpose |
|--------|-----------|---------|
| `USING (expr)` | SELECT, UPDATE, DELETE | Filter which existing rows are visible |
| `WITH CHECK (expr)` | INSERT, UPDATE | Validate new/modified row data |

```sql
-- SELECT: only see your own rows
CREATE POLICY "users_select_own" ON documents
  FOR SELECT USING (user_id = auth.uid());

-- INSERT: can only insert rows owned by you
CREATE POLICY "users_insert_own" ON documents
  FOR INSERT WITH CHECK (user_id = auth.uid());

-- UPDATE: can only see AND modify your own rows
CREATE POLICY "users_update_own" ON documents
  FOR UPDATE
  USING (user_id = auth.uid())
  WITH CHECK (user_id = auth.uid());

-- DELETE: can only delete your own rows
CREATE POLICY "users_delete_own" ON documents
  FOR DELETE USING (user_id = auth.uid());
```

## Auth Functions

| Function | Returns | Use Case |
|----------|---------|----------|
| `auth.uid()` | UUID of authenticated user | Ownership checks |
| `auth.jwt()` | Full JWT claims as JSON | Custom claims, roles, tenant ID |
| `auth.role()` | Current role string | Distinguish anon vs authenticated |

```sql
-- Access custom JWT claims
auth.jwt() ->> 'tenant_id'
auth.jwt() -> 'app_metadata' ->> 'role'
```

## Common Patterns

### 1. Ownership

```sql
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

CREATE POLICY "owner_all" ON documents
  FOR ALL USING (user_id = auth.uid())
  WITH CHECK (user_id = auth.uid());
```

### 2. Tenant Isolation

```sql
-- Requires tenant_id in JWT app_metadata
CREATE POLICY "tenant_isolation" ON orders
  FOR ALL
  USING (tenant_id = (auth.jwt() -> 'app_metadata' ->> 'tenant_id')::uuid)
  WITH CHECK (tenant_id = (auth.jwt() -> 'app_metadata' ->> 'tenant_id')::uuid);
```

### 3. Role-Based Access

```sql
-- Admins see everything, users see own
CREATE POLICY "admin_full_access" ON documents
  FOR ALL USING (
    auth.jwt() -> 'app_metadata' ->> 'role' = 'admin'
  );

CREATE POLICY "user_own_access" ON documents
  FOR ALL USING (user_id = auth.uid())
  WITH CHECK (user_id = auth.uid());
```

### 4. Public Read, Authenticated Write

```sql
CREATE POLICY "public_read" ON posts
  FOR SELECT USING (published = true);

CREATE POLICY "auth_write" ON posts
  FOR INSERT WITH CHECK (auth.role() = 'authenticated');
```

### 5. Team/Organization Access

```sql
CREATE POLICY "team_access" ON projects
  FOR SELECT USING (
    EXISTS (
      SELECT 1 FROM team_members
      WHERE team_members.team_id = projects.team_id
      AND team_members.user_id = auth.uid()
    )
  );
```

## Index Recommendations

Always index columns used in RLS policies for performance:

```sql
CREATE INDEX idx_documents_user_id ON documents (user_id);
CREATE INDEX idx_orders_tenant_id ON orders (tenant_id);
CREATE INDEX idx_team_members_lookup ON team_members (team_id, user_id);
```

## Testing RLS Policies

### Via SQL (Supabase SQL Editor)

```sql
-- Test as a specific user
SET request.jwt.claims = '{"sub": "user-uuid-here", "role": "authenticated",
  "app_metadata": {"tenant_id": "tenant-uuid", "role": "admin"}}';
SET role = 'authenticated';

SELECT * FROM documents; -- should only return rows matching policy

RESET role;
RESET request.jwt.claims;
```

### Via Client (different auth contexts)

```ts
// Test with anon key (unauthenticated)
const anonClient = createClient(url, anonKey);
const { data } = await anonClient.from("documents").select("*");
// Should return empty if no public policy

// Test with authenticated user
const { data: userData } = await authClient.from("documents").select("*");
// Should return only user's rows
```

## Migration Pattern

```sql
-- migration: 001_enable_rls.sql
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE team_members ENABLE ROW LEVEL SECURITY;

-- Always pair with policies
CREATE POLICY "documents_owner" ON documents
  FOR ALL USING (user_id = auth.uid())
  WITH CHECK (user_id = auth.uid());
```

## Common Mistakes

| Wrong | Right | Why |
|-------|-------|-----|
| Enable RLS without policies | Enable RLS + create policies | No policies = no access at all |
| `FOR ALL USING (true)` | Specific conditions per operation | Grants unrestricted access, defeats RLS |
| UPDATE with only USING | UPDATE with USING + WITH CHECK | User could change user_id to another user |
| Complex subqueries in policies | Simple conditions + indexed columns | Subqueries in policies cause N+1 perf issues |
| Using `anon` key as service_role | Separate keys, service_role only on backend | anon key respects RLS (correct), don't confuse |
| RLS on some tables but not others | RLS on ALL tables with user data | Attackers target unprotected tables |
| `auth.uid()` without null check | `auth.uid() IS NOT NULL AND user_id = auth.uid()` | Prevents anon access when policy is permissive |
| Forgetting junction table RLS | RLS on junction tables too | team_members without RLS leaks membership |
