# Pattern 14 — Org-scoped access via JWT claims

## What this is

An alternative to the users-table subquery pattern (pattern 09). The org ID is embedded in JWT `app_metadata` and read directly in policies — no database lookup required. Faster per request; requires token rotation on org membership changes.

## Subquery vs JWT: choose deliberately

| | Pattern 09 (subquery) | Pattern 14 (JWT claim) |
|---|---|---|
| **Performance** | 1 cached subquery per statement | No query (token read only) |
| **Org change effect** | Immediate (next query sees new org) | Stale until token refresh / re-login |
| **Session invalidation** | Automatic | Requires revoking session or waiting for JWT expiry |
| **Implementation** | Simpler (no token management needed) | Requires token update on org change |

For most SaaS apps where org changes are infrequent (user joins one org and stays), the JWT pattern is faster with acceptable trade-offs. For apps where org switching is frequent or org eviction must be immediate, use the subquery pattern.

## The null safety property

When `app_metadata.org_id` is missing, `jwt_org_id()` returns `NULL`. In PostgreSQL, `column = NULL` always evaluates to NULL (not true, not false) — which RLS treats as deny. Missing-claim tokens cannot access any rows.

This is the same safe behaviour as pattern 09 when the user doesn't exist in the `users` table — both patterns deny access when the identifying data is absent, which is the correct default.

## Updating claims (server-side)

```ts
// On org join or reassignment
await supabaseAdmin.auth.admin.updateUserById(userId, {
  app_metadata: { org_id: newOrgId }
});

// For immediate session invalidation (not just waiting for JWT expiry):
await supabaseAdmin.auth.admin.signOut(userId, 'global');
```

## Testing

```sql
-- Valid org claim
set request.jwt.claims = '{"sub": "<uid>", "app_metadata": {"org_id": "<org-id>"}}';
select count(*) from documents;
-- Returns org's row count

-- Missing claim — deny-by-default
set request.jwt.claims = '{"sub": "<uid>", "app_metadata": {}}';
select count(*) from documents;
-- Returns 0

-- jwt_org_id() returns null on missing claim
select jwt_org_id();
-- Returns: null
```

## Common mistake

Not revoking sessions when a user leaves an org. The old JWT remains valid until expiry (default 1 hour in Supabase). For sensitive org eviction, call `signOut` via the Admin API in addition to updating `app_metadata`.
