feature: auth agent references (#36)

* feat: auth agent references

* Update skills/supabase/references/auth-core-sessions.md

Co-authored-by: Stojan Dimitrovski <sdimitrovski@gmail.com>

* refactor: improve auth state management and update session handling examples

* docs: update performance note for asymmetric JWTs

* fix: correct signOut() default scope to 'global'

signOut() defaults to scope: 'global' (all sessions on all devices),
not current session only.

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

* fix: improve signOut() examples to show all three scope options

Replace redundant explicit 'global' example with 'local' scope,
making all three options (global, local, others) visible.

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

* refactor: flatten auth references to root references directory

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

* correct auth hooks

* correct auth server ssr

* fix auth reference files

* fix paths inside skill.md

* update agents.md

---------

Co-authored-by: Greg Richardson <greg.nmr@gmail.com>
Co-authored-by: Stojan Dimitrovski <sdimitrovski@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Pedro Rodrigues
2026-02-13 15:42:00 +00:00
committed by Pedro Rodrigues
parent 36390c2528
commit 6bb9a7cef0
18 changed files with 3827 additions and 11 deletions

View File

@@ -26,16 +26,34 @@ supabase/
| Priority | Category | Impact | Prefix |
|----------|----------|--------|--------|
| 1 | Database | CRITICAL | `db-` |
| 2 | Edge Functions | HIGH | `edge-` |
| 3 | SDK | HIGH | `sdk-` |
| 4 | Realtime | MEDIUM-HIGH | `realtime-` |
| 5 | Storage | HIGH | `storage-` |
| 1 | Authentication | CRITICAL | `auth-` |
| 2 | Database | CRITICAL | `db-` |
| 3 | Edge Functions | HIGH | `edge-` |
| 4 | SDK | HIGH | `sdk-` |
| 5 | Realtime | MEDIUM-HIGH | `realtime-` |
| 6 | Storage | HIGH | `storage-` |
Reference files are named `{prefix}-{topic}.md` (e.g., `query-missing-indexes.md`).
## Available References
**Authentication** (`auth-`):
- `references/auth-core-sessions.md`
- `references/auth-core-signin.md`
- `references/auth-core-signup.md`
- `references/auth-hooks-custom-claims.md`
- `references/auth-hooks-send-email-http.md`
- `references/auth-hooks-send-email-sql.md`
- `references/auth-mfa-phone.md`
- `references/auth-mfa-totp.md`
- `references/auth-oauth-pkce.md`
- `references/auth-oauth-providers.md`
- `references/auth-passwordless-magic-links.md`
- `references/auth-passwordless-otp.md`
- `references/auth-server-admin-api.md`
- `references/auth-server-ssr.md`
- `references/auth-sso-saml.md`
**Database** (`db-`):
- `references/db-conn-pooling.md`
- `references/db-migrations-diff.md`
@@ -110,4 +128,4 @@ Reference files are named `{prefix}-{topic}.md` (e.g., `query-missing-indexes.md
---
*62 reference files across 5 categories*
*77 reference files across 6 categories*

View File

@@ -18,6 +18,18 @@ Supabase is an open source Firebase alternative that provides a Postgres databas
Reference the appropriate resource file based on the user's needs:
### Authentication & Security
| Area | Resource | When to Use |
| ------------------ | ----------------------------------- | -------------------------------------------------------- |
| Auth Core | `references/auth-core-*.md` | Sign-up, sign-in, sessions, password reset |
| OAuth/Social | `references/auth-oauth-*.md` | Google, GitHub, Apple login, PKCE flow |
| Enterprise SSO | `references/auth-sso-*.md` | SAML 2.0, enterprise identity providers |
| MFA | `references/auth-mfa-*.md` | TOTP authenticator apps, phone MFA, AAL levels |
| Passwordless | `references/auth-passwordless-*.md`| Magic links, email OTP, phone OTP |
| Auth Hooks | `references/auth-hooks-*.md` | Custom JWT claims, send email hooks (HTTP and SQL) |
| Server-Side Auth | `references/auth-server-*.md` | Admin API, SSR with Next.js/SvelteKit, service role auth |
### Database
| Area | Resource | When to Use |

View File

@@ -5,27 +5,32 @@ queries.
---
## 1. Database (db)
## 1. Authentication (auth)
**Impact:** CRITICAL
**Description:** Sign-up, sign-in, sign-out, session management, OAuth/social login, SAML SSO, MFA, passwordless flows, auth hooks, and server-side auth patterns.
## 2. Database (db)
**Impact:** CRITICAL
**Description:** Row Level Security policies, connection pooling, schema design patterns, migrations, performance optimization, and security functions for Supabase Postgres.
## 2. Edge Functions (edge)
## 3. Edge Functions (edge)
**Impact:** HIGH
**Description:** Fundamentals, authentication, database access, CORS, routing, error handling, streaming, WebSockets, regional invocations, testing, and limits.
## 3. SDK (sdk)
## 4. SDK (sdk)
**Impact:** HIGH
**Description:** supabase-js client initialization, TypeScript generation, CRUD queries, filters, joins, RPC calls, error handling, performance, and Next.js integration.
## 4. Realtime (realtime)
## 5. Realtime (realtime)
**Impact:** MEDIUM-HIGH
**Description:** Channel setup, Broadcast messaging, Presence tracking, Postgres Changes listeners, cleanup patterns, error handling, and debugging.
## 5. Storage (storage)
## 6. Storage (storage)
**Impact:** HIGH
**Description:** File uploads (standard and resumable), downloads, signed URLs, image transformations, CDN caching, access control with RLS policies, and file management operations.

View File

@@ -0,0 +1,174 @@
---
title: Manage Auth Sessions Correctly
impact: CRITICAL
impactDescription: Session mismanagement causes auth failures, security issues, and poor UX
tags: auth, sessions, tokens, refresh, onAuthStateChange, jwt
---
## Manage Auth Sessions Correctly
Session lifecycle, token refresh, and auth state listeners.
## Session Structure
```typescript
interface Session {
access_token: string // JWT, expires in 1 hour (default)
refresh_token: string // Single-use, used to get new access_token
expires_at: number // Unix timestamp when access_token expires
expires_in: number // Seconds until expiration
user: User // User object with id, email, metadata
}
```
## Listen to Auth State Changes
`onAuthStateChange` is the single source of truth for auth state. In React, wrap it with `useSyncExternalStore` for safe, tear-free subscriptions:
```typescript
import { useSyncExternalStore } from 'react'
let currentSession: Session | null = null
function subscribe(callback: () => void) {
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
currentSession = session
callback()
}
)
return () => subscription.unsubscribe()
}
function getSnapshot() {
return currentSession
}
// In your component
function useSession() {
return useSyncExternalStore(subscribe, getSnapshot)
}
```
### Auth Events
| Event | When Fired |
|-------|------------|
| `INITIAL_SESSION` | Right after client is constructed and initial session from storage is loaded |
| `SIGNED_IN` | User signed in successfully |
| `SIGNED_OUT` | User signed out |
| `TOKEN_REFRESHED` | Access token was refreshed |
| `USER_UPDATED` | User profile was updated |
| `PASSWORD_RECOVERY` | User clicked password reset link |
## Get Current Session
```typescript
// From local storage (fast, but not validated)
const { data: { session } } = await supabase.auth.getSession()
// Validate on server: getClaims() validates JWT locally (fast, preferred with asymmetric keys)
const { data: claims } = await supabase.auth.getClaims()
// Or use getUser() which round-trips to Auth server (always accurate)
const { data: { user } } = await supabase.auth.getUser()
```
## Common Mistakes
### 1. Using getSession() for Server-Side Validation
**Incorrect:**
```typescript
// Server-side code
const { data: { session } } = await supabase.auth.getSession()
// DANGER: getSession reads from storage, doesn't validate JWT
const userId = session?.user.id
```
**Correct:**
```typescript
// Server-side code - always use getClaims() which validates the JWT
const { data, error } = await supabase.auth.getClaims()
if (error || !data) {
return unauthorizedResponse()
}
const userId = data.claims.sub
```
> **Asymmetric JWTs:** Asymmetric JWT signing keys are the recommended approach. Symmetric signing is not recommended for production. With asymmetric keys, `getClaims()` verifies tokens locally via WebCrypto with no network request, significantly faster than `getUser()` which always round-trips to the Auth server.
### 2. Calling Supabase in onAuthStateChange Without Deferring
**Incorrect:**
```typescript
supabase.auth.onAuthStateChange(async (event, session) => {
// This can cause deadlocks
await supabase.from('profiles').select('*')
})
```
**Correct:**
```typescript
supabase.auth.onAuthStateChange((event, session) => {
// Defer Supabase calls with setTimeout
setTimeout(async () => {
await supabase.from('profiles').select('*')
}, 0)
})
```
### 3. Not Unsubscribing from Auth Listener
**Incorrect:**
```typescript
useEffect(() => {
supabase.auth.onAuthStateChange((event, session) => {
setUser(session?.user)
})
// Missing cleanup - causes memory leaks
}, [])
```
**Correct:**
```typescript
useEffect(() => {
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => {
setUser(session?.user ?? null)
}
)
return () => subscription.unsubscribe()
}, [])
```
## JWT Claims
Access user claims in RLS policies:
```sql
-- Get user ID
auth.uid() -- Returns UUID
-- Get full JWT
auth.jwt() -- Returns JSONB
-- Get specific claims
auth.jwt() ->> 'email' -- User email
auth.jwt() -> 'app_metadata' ->> 'role' -- Custom role (admin-set)
auth.jwt() ->> 'aal' -- Auth assurance level (aal1/aal2)
```
## Related
- [core-signin.md](core-signin.md) - Sign-in flows
- [server-ssr.md](server-ssr.md) - Server-side session handling
- [Docs: Sessions](https://supabase.com/docs/guides/auth/sessions)
- [Docs: onAuthStateChange](https://supabase.com/docs/reference/javascript/auth-onauthstatechange)

View File

@@ -0,0 +1,179 @@
---
title: Implement Sign-In and Password Reset
impact: CRITICAL
impactDescription: Core authentication flows - mistakes lead to security issues or locked-out users
tags: auth, signin, login, password-reset, email
---
## Implement Sign-In and Password Reset
Email/password authentication and secure password recovery flows.
## Sign In
```typescript
const { data, error } = await supabase.auth.signInWithPassword({
email: 'user@example.com',
password: 'password123',
})
if (error) {
console.error('Sign in failed:', error.message)
} else {
console.log('Signed in:', data.user.email)
// data.session contains access_token and refresh_token
}
```
## Password Reset Flow
### Step 1: Request Reset Email
```typescript
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: 'https://yourapp.com/update-password',
})
if (!error) {
showMessage('Check your email for password reset link')
}
```
### Step 2: Handle Reset Link and Update Password
```typescript
// On your /update-password page
supabase.auth.onAuthStateChange(async (event, session) => {
if (event === 'PASSWORD_RECOVERY') {
const newPassword = await promptForNewPassword()
const { error } = await supabase.auth.updateUser({
password: newPassword,
})
if (!error) {
showMessage('Password updated successfully')
router.push('/dashboard')
}
}
})
```
## Common Mistakes
### 1. Exposing Whether Email Exists
**Incorrect:**
```typescript
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error?.message === 'Invalid login credentials') {
// Don't differentiate between wrong email vs wrong password
showError('Email not found')
}
```
**Correct:**
```typescript
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) {
// Generic message prevents email enumeration attacks
showError('Invalid email or password')
}
```
### 2. Not Handling Rate Limiting
**Incorrect:**
```typescript
// No retry logic or user feedback for rate limits
const { error } = await supabase.auth.signInWithPassword({ email, password })
```
**Correct:**
```typescript
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) {
if (error.message.includes('rate limit')) {
showError('Too many attempts. Please wait a moment and try again.')
} else {
showError('Invalid email or password')
}
}
```
### 3. Missing Redirect URL in Password Reset
**Incorrect:**
```typescript
// User lands on Supabase default page instead of your app
await supabase.auth.resetPasswordForEmail(email)
```
**Correct:**
```typescript
// Stop and ask the user to add the redirect URL to the allowlist in the Supabase Dashboard under Auth > URL Configuration > Redirect URLs
await supabase.auth.resetPasswordForEmail(email, {
redirectTo: 'https://yourapp.com/update-password',
})
```
### 4. Not Listening for PASSWORD_RECOVERY Event
**Incorrect:**
```typescript
// On /update-password page - tries to update immediately
const { error } = await supabase.auth.updateUser({ password: newPassword })
// Error: No session - user hasn't been authenticated yet
```
**Correct:**
```typescript
// Listen for the recovery event which establishes the session
supabase.auth.onAuthStateChange(async (event, session) => {
if (event === 'PASSWORD_RECOVERY') {
// Now there's a valid session
const { error } = await supabase.auth.updateUser({ password: newPassword })
}
})
```
## Sign Out
```typescript
// Sign out all sessions — this is the default (scope: 'global')
const { error } = await supabase.auth.signOut()
// Sign out current session only (keep other devices active)
const { error } = await supabase.auth.signOut({ scope: 'local' })
// Sign out other sessions only (keep current)
const { error } = await supabase.auth.signOut({ scope: 'others' })
```
## Update User Email
```typescript
const { error } = await supabase.auth.updateUser({
email: 'newemail@example.com',
})
// If "Secure Email Change" is enabled (default), confirmation emails sent to BOTH old and new email
// User must confirm on both to complete the change
// If disabled, only a single confirmation to the new email is required
```
## Related
- [core-sessions.md](core-sessions.md) - Managing sessions after sign-in
- [mfa-totp.md](mfa-totp.md) - Adding MFA to sign-in
- [Docs: Sign In](https://supabase.com/docs/reference/javascript/auth-signinwithpassword)
- [Docs: Password Reset](https://supabase.com/docs/reference/javascript/auth-resetpasswordforemail)

View File

@@ -0,0 +1,178 @@
---
title: Implement Secure User Sign-Up
impact: CRITICAL
impactDescription: Foundation of user registration - mistakes lead to security vulnerabilities or broken flows
tags: auth, signup, registration, email-confirmation, user-metadata
---
## Implement Secure User Sign-Up
User registration with email/password, including confirmation flow and metadata handling.
## Quick Start
```typescript
const { data, error } = await supabase.auth.signUp({
email: 'user@example.com',
password: 'securepassword123',
options: {
data: {
full_name: 'John Doe',
},
emailRedirectTo: 'https://yourapp.com/auth/callback',
},
})
// Check if email confirmation is required
if (data.user && !data.session) {
// Email confirmation enabled - user must verify email
console.log('Check your email for confirmation link')
}
```
## User Metadata
Store user-specific data at sign-up using `options.data`:
```typescript
const { data, error } = await supabase.auth.signUp({
email: 'user@example.com',
password: 'password123',
options: {
data: {
full_name: 'Jane Smith',
avatar_url: 'https://example.com/avatar.png',
organization: 'Acme Inc',
},
},
})
// Access metadata later
const name = data.user?.user_metadata?.full_name
```
**Important:** `user_metadata` is user-modifiable. Never use it for authorization decisions like roles or permissions. Use `app_metadata` (set server-side only) for that.
## Common Mistakes
### 1. Not Handling Email Confirmation State
**Incorrect:**
```typescript
const { data, error } = await supabase.auth.signUp({ email, password })
if (data.user) {
// Assumes user is logged in
router.push('/dashboard')
}
```
**Correct:**
```typescript
const { data, error } = await supabase.auth.signUp({ email, password })
if (error) {
showError(error.message)
} else if (data.user && !data.session) {
// Email confirmation required
showMessage('Please check your email to confirm your account')
} else if (data.session) {
// Email confirmation disabled - user is logged in
router.push('/dashboard')
}
```
### 2. Missing Redirect URL Configuration
**Incorrect:**
```typescript
// Redirect URL not in allowlist - redirects to Site URL instead
await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: 'https://myapp.com/welcome',
},
})
```
**Correct:**
1. Stop and ask the user to add the redirect URL to the allowlist in the Supabase Dashboard under Auth > URL Configuration > Redirect URLs
2. Then use it in code:
```typescript
await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: 'https://myapp.com/welcome', // Must be in allowlist
},
})
```
### 3. Exposing Whether Email Exists
**Incorrect:**
```typescript
if (error?.message.includes('already registered')) {
showError('This email is already taken')
}
```
**Correct:**
```typescript
// Show generic message to prevent email enumeration
if (error) {
showError('Unable to create account. Please try again.')
}
// For legitimate users, they can use password reset
```
## Create User Profile on Sign-Up
Use a database trigger to auto-create profiles:
```sql
-- Create profiles table
create table public.profiles (
id uuid references auth.users(id) on delete cascade primary key,
full_name text,
avatar_url text,
created_at timestamptz default now()
);
alter table public.profiles enable row level security;
-- Auto-create profile on sign-up
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
insert into public.profiles (id, full_name, avatar_url)
values (
new.id,
new.raw_user_meta_data ->> 'full_name',
new.raw_user_meta_data ->> 'avatar_url'
);
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
```
> **Warning:** If the trigger function fails, it will block sign-ups entirely. Test your trigger code thoroughly.
## Related
- [core-sessions.md](core-sessions.md) - Session management after sign-up
- [Docs: Sign Up](https://supabase.com/docs/reference/javascript/auth-signup)
- [Docs: Email Templates](https://supabase.com/docs/guides/auth/auth-email-templates)

View File

@@ -0,0 +1,285 @@
---
title: Add Custom Claims to JWT via Auth Hooks
impact: HIGH
impactDescription: Custom claims enable role-based access without database lookups in every request
tags: auth, hooks, jwt, claims, rbac, roles, permissions
---
## Add Custom Claims to JWT via Auth Hooks
Use the Custom Access Token Hook to add custom claims (roles, permissions, tenant IDs) to JWTs. Claims are then available in RLS policies without database lookups.
## When to Use
- **Role-based access control (RBAC)**: Add user roles to JWT
- **Multi-tenancy**: Add organization/tenant ID
- **Feature flags**: Add subscription tier or enabled features
- **Reduce RLS complexity**: Avoid joins in every policy
## Setup
### Step 1: Create the Hook Function
```sql
-- Create a roles table (if needed)
create table public.user_roles (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users(id) on delete cascade not null,
role text not null check (role in ('admin', 'moderator', 'user')),
unique(user_id)
);
-- Create the hook function
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
stable
as $$
declare
claims jsonb;
user_role text;
begin
-- Get user's role from your table
select role into user_role
from public.user_roles
where user_id = (event ->> 'user_id')::uuid;
-- Get existing claims
claims := event -> 'claims';
-- Add custom claims
if user_role is not null then
claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
else
claims := jsonb_set(claims, '{user_role}', '"user"'); -- Default role
end if;
-- Return modified event
return jsonb_set(event, '{claims}', claims);
end;
$$;
-- Grant required permissions
grant usage on schema public to supabase_auth_admin;
grant all on table public.user_roles to supabase_auth_admin;
grant execute on function public.custom_access_token_hook to supabase_auth_admin;
-- Revoke from other roles for security
revoke all on table public.user_roles from authenticated, anon, public;
revoke execute on function public.custom_access_token_hook from authenticated, anon, public;
```
### Step 2: Enable the Hook
Stop and ask the user to enable the hook in the Supabase Dashboard under Auth > Hooks > Customize Access Token (JWT) Claims, and select the function `public.custom_access_token_hook`.
### Step 3: Use Claims in RLS Policies
```sql
-- Check role in policy
create policy "Admins can delete"
on posts for delete
to authenticated
using ((auth.jwt() ->> 'user_role') = 'admin');
-- Check multiple roles
create policy "Moderators and admins can update"
on posts for update
to authenticated
using ((auth.jwt() ->> 'user_role') in ('admin', 'moderator'));
```
## Multi-Tenant Example
```sql
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
stable
as $$
declare
claims jsonb;
org_id uuid;
begin
-- Get user's organization
select organization_id into org_id
from public.organization_members
where user_id = (event ->> 'user_id')::uuid
limit 1;
claims := event -> 'claims';
if org_id is not null then
claims := jsonb_set(claims, '{org_id}', to_jsonb(org_id));
end if;
return jsonb_set(event, '{claims}', claims);
end;
$$;
```
Use in RLS:
```sql
create policy "Users see org data"
on organization_data for select
to authenticated
using (org_id = (auth.jwt() ->> 'org_id')::uuid);
```
## Common Mistakes
### 1. Expensive Queries in Hook
**Incorrect:**
```sql
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
as $$
begin
-- Multiple joins and complex query - slows every token refresh
select ... from users
join organizations on ...
join permissions on ...
join features on ...
-- This runs on every sign-in and token refresh!
end;
$$;
```
**Correct:**
```sql
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
stable -- Mark as stable for potential caching
as $$
declare
claims jsonb;
user_role text;
begin
-- Simple indexed lookup
select role into user_role
from public.user_roles
where user_id = (event ->> 'user_id')::uuid;
-- Keep it fast
claims := event -> 'claims';
claims := jsonb_set(claims, '{user_role}', to_jsonb(coalesce(user_role, 'user')));
return jsonb_set(event, '{claims}', claims);
end;
$$;
```
### 2. Not Handling NULL Values
**Incorrect:**
```sql
-- If no role found, sets claim to null (may cause issues)
claims := jsonb_set(claims, '{role}', to_jsonb(user_role));
```
**Correct:**
```sql
-- Provide default value
claims := jsonb_set(claims, '{role}', to_jsonb(coalesce(user_role, 'user')));
```
### 3. Forgetting Claims are Cached in JWT
**Issue:** JWT claims don't update until token refresh (default: 1 hour).
**Incorrect:**
```typescript
// Change user role
await updateUserRole(userId, 'admin')
// User won't see new role until token refresh
```
**Correct:**
```typescript
// Change user role
await updateUserRole(userId, 'admin')
// Force session refresh to get new claims
await supabase.auth.refreshSession()
```
Or inform user:
```typescript
showMessage('Your role has been updated. Changes will take effect within an hour.')
```
### 4. Hook Function in Public Schema Without Protection
**Incorrect:**
```sql
-- Function accessible via API if in public schema without restrictions
create function public.custom_access_token_hook(event jsonb)
returns jsonb as $$ ... $$;
```
**Correct:**
```sql
-- Revoke access from API-accessible roles
revoke execute on function public.custom_access_token_hook
from authenticated, anon, public;
-- Only supabase_auth_admin should call it
grant execute on function public.custom_access_token_hook
to supabase_auth_admin;
```
## Hook Input Fields
The hook receives a JSON event with:
- `user_id` - UUID of the user requesting the token
- `claims` - existing JWT claims to modify
- `authentication_method` - how the user authenticated (`password`, `otp`, `oauth`, `totp`, `recovery`, `invite`, `sso/saml`, `magiclink`, `email/signup`, `email_change`, `token_refresh`, `anonymous`)
## JWT Claims Structure After Hook
```json
{
"aud": "authenticated",
"exp": 1234567890,
"sub": "user-uuid",
"email": "user@example.com",
"user_role": "admin", // Your custom claim
"org_id": "org-uuid", // Your custom claim
"aal": "aal1",
"session_id": "session-uuid"
}
```
Access in client (for display only - use RLS for authorization):
```typescript
import { jwtDecode } from 'jwt-decode'
const { data: { session } } = await supabase.auth.getSession()
if (session?.access_token) {
const decoded = jwtDecode<{ user_role?: string }>(session.access_token)
const userRole = decoded.user_role // Your custom claim
}
// Note: Custom claims are in the JWT payload, NOT in user_metadata
// For authorization, use auth.jwt() in RLS policies instead
```
## Related
- [hooks-send-email.md](hooks-send-email.md) - Custom email hooks
- [../db/rls-common-mistakes.md](../db/rls-common-mistakes.md) - RLS patterns
- [Docs: Auth Hooks](https://supabase.com/docs/guides/auth/auth-hooks)

View File

@@ -0,0 +1,287 @@
---
title: Send Email Hook via HTTP (Edge Function)
impact: MEDIUM
impactDescription: Custom email hooks enable branded templates and third-party email providers
tags: auth, hooks, email, templates, resend, edge-functions, http
---
## Send Email Hook via HTTP (Edge Function)
Use a Supabase Edge Function as an HTTP endpoint to intercept and customize auth emails with your own templates and provider.
## When to Use
- **Custom email design**: Full control over HTML templates
- **Third-party providers**: Resend, SendGrid, Mailgun, Postmark
- **Localization**: Multi-language email support
- **Real-time delivery**: Emails sent immediately on auth events
## Setup
### Step 1: Create Edge Function
```typescript
// supabase/functions/send-email/index.ts
import { Webhook } from "https://esm.sh/standardwebhooks@1.0.0";
import { Resend } from "npm:resend";
type EmailActionType =
| "signup"
| "recovery"
| "magiclink"
| "email_change"
| "invite"
| "reauthentication";
interface SendEmailPayload {
user: {
email: string;
email_new?: string;
user_metadata?: Record<string, any>;
};
email_data: {
token: string;
token_new: string;
/**
* Counterintuitive: token_hash is used with the NEW email address.
* Do not assume the _new suffix refers to the new email.
*/
token_hash: string;
/**
* Counterintuitive: token_hash_new is used with the CURRENT email address.
* Do not assume the _new suffix refers to the new email.
*/
token_hash_new: string;
redirect_to: string;
email_action_type: EmailActionType;
site_url: string;
};
}
const resend = new Resend(Deno.env.get("RESEND_API_KEY") as string);
const hookSecret = (Deno.env.get("SEND_EMAIL_HOOK_SECRET") as string).replace(
"v1,whsec_",
""
);
const subjectMap: Record<EmailActionType, string> = {
signup: "Confirm your email",
recovery: "Reset your password",
magiclink: "Your magic link",
email_change: "Confirm email change",
invite: "You've been invited",
reauthentication: "Confirm your identity",
};
Deno.serve(async (req) => {
if (req.method !== "POST") {
return new Response("not allowed", { status: 400 });
}
const payload = await req.text();
const headers = Object.fromEntries(req.headers);
const wh = new Webhook(hookSecret);
try {
const { user, email_data } = wh.verify(
payload,
headers
) as SendEmailPayload;
const subject =
subjectMap[email_data.email_action_type] ?? "Notification";
const { error } = await resend.emails.send({
from: "welcome <onboarding@yourapp.com>",
to: [user.email],
subject,
html: `<p>Your code: <strong>${email_data.token}</strong></p>`,
});
if (error) throw error;
} catch (error: any) {
return new Response(
JSON.stringify({
error: {
http_code: error.code,
message: error.message,
},
}),
{ status: 401, headers: { "Content-Type": "application/json" } }
);
}
return new Response(JSON.stringify({}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
```
### Step 2: Deploy Function
```bash
supabase functions deploy send-email --no-verify-jwt
```
### Step 3: Set Secrets
```bash
supabase secrets set SEND_EMAIL_HOOK_SECRET="v1,whsec_<base64_secret>"
supabase secrets set RESEND_API_KEY=re_xxxxx
```
### Step 4: Enable Hook
Stop and ask the user to enable the hook in the Supabase Dashboard under **Auth > Hooks > Send Email** with these settings:
- Enabled: Yes
- URI: `https://<project-ref>.supabase.co/functions/v1/send-email`
CLI config (`config.toml`):
```toml
[auth.hook.send_email]
enabled = true
uri = "https://<project-ref>.supabase.co/functions/v1/send-email"
secrets = "v1,whsec_xxxxx"
```
## Email Action Types
| Type | Trigger |
|------|---------|
| `signup` | New user signs up with email |
| `recovery` | Password reset requested |
| `magiclink` | Magic link sign-in |
| `email_change` | User changes email address |
| `invite` | User invited to project |
| `reauthentication` | Reauthentication required |
## Secure Email Change (Counterintuitive Field Naming)
The token hash field names are **reversed** due to backward compatibility:
- `token_hash_new` → use with the **current** email address (`user.email`) and `token`
- `token_hash` → use with the **new** email address (`user.email_new`) and `token_new`
Do **not** assume the `_new` suffix refers to the new email address.
**Secure email change enabled (two OTPs):**
- Send to current email (`user.email`): use `token` / `token_hash_new`
- Send to new email (`user.email_new`): use `token_new` / `token_hash`
**Secure email change disabled (one OTP):**
- Send a single email to the new address using whichever token/hash pair is populated
## Internationalization (Optional)
Ask the user if they want i18n support. If yes, use the user's locale from `user.user_metadata.i18n` (or a similar field) to select localized subjects and templates:
```typescript
const subjects: Record<string, Record<EmailActionType | "email_change_new", string>> = {
en: {
signup: "Confirm Your Email",
recovery: "Reset Your Password",
invite: "You have been invited",
magiclink: "Your Magic Link",
email_change: "Confirm Email Change",
email_change_new: "Confirm New Email Address",
reauthentication: "Confirm Reauthentication",
},
es: {
signup: "Confirma tu correo electrónico",
recovery: "Restablece tu contraseña",
invite: "Has sido invitado",
magiclink: "Tu enlace mágico",
email_change: "Confirma el cambio de correo electrónico",
email_change_new: "Confirma la Nueva Dirección de Correo",
reauthentication: "Confirma la reautenticación",
},
};
const templates: Record<string, Record<EmailActionType | "email_change_new", string>> = {
en: {
signup: `<h2>Confirm your email</h2><p>Follow this link to confirm your email:</p><p><a href="{{confirmation_url}}">Confirm your email address</a></p><p>Alternatively, enter the code: {{token}}</p>`,
recovery: `<h2>Reset password</h2><p>Follow this link to reset the password for your user:</p><p><a href="{{confirmation_url}}">Reset password</a></p><p>Alternatively, enter the code: {{token}}</p>`,
invite: `<h2>You have been invited</h2><p>You have been invited to create a user on {{site_url}}. Follow this link to accept the invite:</p><p><a href="{{confirmation_url}}">Accept the invite</a></p><p>Alternatively, enter the code: {{token}}</p>`,
magiclink: `<h2>Magic Link</h2><p>Follow this link to login:</p><p><a href="{{confirmation_url}}">Log In</a></p><p>Alternatively, enter the code: {{token}}</p>`,
email_change: `<h2>Confirm email address change</h2><p>Follow this link to confirm the update of your email address from {{old_email}} to {{new_email}}:</p><p><a href="{{confirmation_url}}">Change email address</a></p><p>Alternatively, enter the codes: {{token}} and {{new_token}}</p>`,
email_change_new: `<h2>Confirm New Email Address</h2><p>Follow this link to confirm your new email address:</p><p><a href="{{confirmation_url}}">Confirm new email address</a></p><p>Alternatively, enter the code: {{new_token}}</p>`,
reauthentication: `<h2>Confirm reauthentication</h2><p>Enter the code: {{token}}</p>`,
},
// Add more locales as needed (es, fr, etc.)
};
// Resolve the user's language, defaulting to "en"
const language = user.user_metadata?.i18n || "en";
const subject = subjects[language]?.[email_data.email_action_type] || subjects.en[email_data.email_action_type];
```
## Common Mistakes
### Not Verifying Webhook Signature
**Incorrect:**
```typescript
Deno.serve(async (req) => {
const data = await req.json();
// Directly using unverified data - DANGEROUS
await sendEmail(data.user.email, data.email_data);
});
```
**Correct:**
```typescript
import { Webhook } from "https://esm.sh/standardwebhooks@1.0.0";
Deno.serve(async (req) => {
const payload = await req.text();
const wh = new Webhook(HOOK_SECRET);
try {
const data = wh.verify(payload, Object.fromEntries(req.headers));
await sendEmail(data.user.email, data.email_data);
} catch {
return new Response("Unauthorized", { status: 401 });
}
});
```
### Swapping Token Hash Fields for Email Change
**Incorrect:**
```typescript
// WRONG: assuming _new suffix means the new email
const currentEmailHash = email_data.token_hash;
const newEmailHash = email_data.token_hash_new;
```
**Correct:**
```typescript
// token_hash_new → current email address
// token_hash → new email address
const currentEmailHash = email_data.token_hash_new;
const newEmailHash = email_data.token_hash;
```
## Behavior Matrix
| Email Provider | Auth Hook | Result |
|---|---|---|
| Enabled | Enabled | Hook handles sending |
| Enabled | Disabled | SMTP handles sending |
| Disabled | Enabled | Email signups disabled |
| Disabled | Disabled | Email signups disabled |
## Related
- [auth-hooks-send-email-sql.md](auth-hooks-send-email-sql.md) - SQL (PostgreSQL) approach
- [Docs: Send Email Hook](https://supabase.com/docs/guides/auth/auth-hooks/send-email-hook)
- [Docs: Auth Hooks](https://supabase.com/docs/guides/auth/auth-hooks)

View File

@@ -0,0 +1,285 @@
---
title: Send Email Hook via SQL (PostgreSQL Function)
impact: MEDIUM
impactDescription: Queue-based email sending with PostgreSQL for batch processing and reliability
tags: auth, hooks, email, postgresql, pg_cron, sql, queue
---
## Send Email Hook via SQL (PostgreSQL Function)
Use a PostgreSQL function to intercept auth emails. Instead of sending immediately, messages are queued in a table and sent in periodic intervals via `pg_cron` and an Edge Function.
## When to Use
- **Batch processing**: Send emails in periodic intervals instead of real-time
- **Queue reliability**: Messages persisted in the database before sending
- **No external dependencies at hook time**: The hook itself is a pure SQL function
- **Audit trail**: Full record of all email sends in your database
## Setup
### Step 1: Create the Messages Table
```sql
create table public.email_queue (
id bigint primary key generated always as identity,
receiver jsonb not null, -- { "email": "user@example.com" }
subject text not null,
body text not null,
data jsonb, -- additional template data
created_at timestamptz default now(),
sent_at timestamptz
);
```
### Step 2: Create the Hook Function
```sql
create or replace function public.custom_send_email(event jsonb)
returns jsonb
language plpgsql
as $$
declare
receiver jsonb;
action_type text;
token text;
token_hash text;
token_new text;
token_hash_new text;
site_url text;
redirect_to text;
subject text;
body text;
begin
receiver := event -> 'user' -> 'email';
action_type := event -> 'email_data' ->> 'email_action_type';
token := event -> 'email_data' ->> 'token';
token_new := event -> 'email_data' ->> 'token_new';
site_url := event -> 'email_data' ->> 'site_url';
redirect_to := event -> 'email_data' ->> 'redirect_to';
-- Counterintuitive naming (backward compatibility):
-- token_hash_new → use with CURRENT email and token
-- token_hash → use with NEW email and token_new
token_hash := event -> 'email_data' ->> 'token_hash';
token_hash_new := event -> 'email_data' ->> 'token_hash_new';
case action_type
when 'signup' then
subject := 'Confirm your email';
body := format(
'<h2>Confirm your email</h2><p>Your confirmation code: <strong>%s</strong></p>',
token
);
when 'recovery' then
subject := 'Reset your password';
body := format(
'<h2>Reset password</h2><p>Your reset code: <strong>%s</strong></p>',
token
);
when 'magiclink' then
subject := 'Your magic link';
body := format(
'<h2>Magic Link</h2><p>Your login code: <strong>%s</strong></p>',
token
);
when 'email_change' then
subject := 'Confirm email change';
body := format(
'<h2>Email Change</h2><p>Your confirmation code: <strong>%s</strong></p>',
token
);
when 'invite' then
subject := 'You have been invited';
body := format(
'<h2>Invitation</h2><p>Your invite code: <strong>%s</strong></p>',
token
);
when 'reauthentication' then
subject := 'Confirm your identity';
body := format(
'<h2>Reauthentication</h2><p>Your code: <strong>%s</strong></p>',
token
);
else
subject := 'Notification';
body := format('<p>Your code: <strong>%s</strong></p>', token);
end case;
insert into public.email_queue (receiver, subject, body, data)
values (
jsonb_build_object('email', receiver),
subject,
body,
event -> 'email_data'
);
return jsonb_build_object();
end;
$$;
-- Grant execute to supabase_auth_admin and revoke from public roles
grant execute on function public.custom_send_email(jsonb) to supabase_auth_admin;
revoke execute on function public.custom_send_email(jsonb) from authenticated, anon, public;
```
### Step 3: Create the Sender Edge Function
```typescript
// supabase/functions/send-queued-emails/index.ts
import { Resend } from "npm:resend";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const resend = new Resend(Deno.env.get("RESEND_API_KEY") as string);
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
Deno.serve(async (req) => {
// Fetch unsent messages
const { data: messages, error } = await supabase
.from("email_queue")
.select("*")
.is("sent_at", null)
.order("created_at")
.limit(100);
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
});
}
let sent = 0;
for (const msg of messages ?? []) {
const { error: sendError } = await resend.emails.send({
from: "noreply <noreply@yourapp.com>",
to: [msg.receiver.email],
subject: msg.subject,
html: msg.body,
});
if (!sendError) {
await supabase
.from("email_queue")
.update({ sent_at: new Date().toISOString() })
.eq("id", msg.id);
sent++;
}
}
return new Response(JSON.stringify({ sent }), {
headers: { "Content-Type": "application/json" },
});
});
```
### Step 4: Schedule with pg_cron
```sql
-- Enable pg_cron if not already enabled
create extension if not exists pg_cron;
-- Run every minute (adjust interval as needed — see crontab.guru)
select cron.schedule(
'send-queued-emails',
'* * * * *',
$$
select net.http_post(
url := 'https://<project-ref>.supabase.co/functions/v1/send-queued-emails',
headers := jsonb_build_object(
'Authorization', 'Bearer ' || current_setting('app.settings.service_role_key')
)
);
$$
);
```
### Step 5: Enable Hook
Stop and ask the user to enable the hook in the Supabase Dashboard under **Auth > Hooks > Send Email** with these settings:
- Enabled: Yes
- Type: PostgreSQL Function
- Schema: `public`
- Function: `custom_send_email`
CLI config (`config.toml`):
```toml
[auth.hook.send_email]
enabled = true
uri = "pg-functions://postgres/public/custom_send_email"
```
## Email Action Types
| Type | Trigger |
|------|---------|
| `signup` | New user signs up with email |
| `recovery` | Password reset requested |
| `magiclink` | Magic link sign-in |
| `email_change` | User changes email address |
| `invite` | User invited to project |
| `reauthentication` | Reauthentication required |
## Secure Email Change (Counterintuitive Field Naming)
The token hash field names are **reversed** due to backward compatibility:
- `token_hash_new` → use with the **current** email address and `token`
- `token_hash` → use with the **new** email address and `token_new`
Do **not** assume the `_new` suffix refers to the new email address.
## Common Mistakes
### Forgetting Permission Grants
**Incorrect:**
```sql
-- Function created but no grants — auth hook fails silently
create or replace function public.custom_send_email(event jsonb) ...
```
**Correct:**
```sql
grant execute on function public.custom_send_email(jsonb) to supabase_auth_admin;
revoke execute on function public.custom_send_email(jsonb) from authenticated, anon, public;
```
### Not Returning an Empty JSON Object
The hook expects a `jsonb` return. Returning `null` or nothing causes the auth flow to fail.
**Incorrect:**
```sql
-- Missing return
return null;
```
**Correct:**
```sql
return jsonb_build_object();
```
## Behavior Matrix
| Email Provider | Auth Hook | Result |
|---|---|---|
| Enabled | Enabled | Hook handles sending |
| Enabled | Disabled | SMTP handles sending |
| Disabled | Enabled | Email signups disabled |
| Disabled | Disabled | Email signups disabled |
## Related
- [auth-hooks-send-email-http.md](auth-hooks-send-email-http.md) - HTTP (Edge Function) approach
- [Docs: Send Email Hook](https://supabase.com/docs/guides/auth/auth-hooks/send-email-hook)
- [Docs: Auth Hooks](https://supabase.com/docs/guides/auth/auth-hooks)

View File

@@ -0,0 +1,256 @@
---
title: Implement Phone-Based MFA
impact: MEDIUM-HIGH
impactDescription: Phone MFA provides alternative second factor for users without authenticator apps
tags: auth, mfa, phone, sms, whatsapp, 2fa
---
## Implement Phone-Based MFA
Add phone-based second factor authentication via SMS or WhatsApp.
## Prerequisites
1. Stop and ask the user to enable "Advanced MFA Phone" in the Supabase Dashboard under Auth > Multi Factor
2. Configure a phone provider (Twilio, MessageBird, Vonage)
## Pricing
For pricing information regarding Advanced MFA Phone, fetch https://supabase.com/docs/guides/platform/manage-your-usage/advanced-mfa-phone
SMS/WhatsApp costs from provider apply separately.
## Enrollment Flow
### Step 1: Enroll Phone Factor
```typescript
const { data, error } = await supabase.auth.mfa.enroll({
factorType: 'phone',
phone: '+1234567890',
})
if (error) throw error
const factorId = data.id
// SMS is NOT sent automatically — you must call challenge() next
```
### Step 2: Verify Phone
```typescript
// Create challenge (triggers SMS)
const { data: challenge, error } = await supabase.auth.mfa.challenge({
factorId,
channel: 'sms', // or 'whatsapp'
})
// Verify with code from SMS
const { error: verifyError } = await supabase.auth.mfa.verify({
factorId,
challengeId: challenge.id,
code: '123456',
})
if (!verifyError) {
// Phone MFA enrolled successfully
}
```
## Sign-In with Phone MFA
```typescript
// After password sign-in, check for MFA
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
if (aal.nextLevel === 'aal2' && aal.currentLevel !== 'aal2') {
// Get enrolled factors
const { data: factors } = await supabase.auth.mfa.listFactors()
const phoneFactor = factors.phone?.[0]
if (phoneFactor) {
// Trigger SMS/WhatsApp verification
const { data: challenge } = await supabase.auth.mfa.challenge({
factorId: phoneFactor.id,
channel: 'sms',
})
// User enters code from SMS
const { error } = await supabase.auth.mfa.verify({
factorId: phoneFactor.id,
challengeId: challenge.id,
code: userEnteredCode,
})
}
}
```
## Common Mistakes
### 1. Not Handling SMS Delivery Failures
**Incorrect:**
```typescript
await supabase.auth.mfa.challenge({ factorId, channel: 'sms' })
// Assume SMS was delivered
showMessage('Enter the code we sent')
```
**Correct:**
```typescript
const { data, error } = await supabase.auth.mfa.challenge({
factorId,
channel: 'sms',
})
if (error) {
showError('Failed to send verification code. Please try again.')
return
}
showMessage('Enter the code we sent')
// Provide resend option
const handleResend = async () => {
await supabase.auth.mfa.challenge({ factorId, channel: 'sms' })
}
```
### 2. Ignoring Rate Limits
**Incorrect:**
```typescript
// User spams "resend code" button
const handleResend = () => {
supabase.auth.mfa.challenge({ factorId, channel: 'sms' })
}
```
**Correct:**
```typescript
const [lastSent, setLastSent] = useState(0)
const COOLDOWN = 60000 // 60 seconds
const handleResend = async () => {
if (Date.now() - lastSent < COOLDOWN) {
showError('Please wait before requesting another code')
return
}
await supabase.auth.mfa.challenge({ factorId, channel: 'sms' })
setLastSent(Date.now())
}
```
### 3. Not Offering Channel Options
**Incorrect:**
```typescript
// Only SMS, no fallback
await supabase.auth.mfa.challenge({ factorId, channel: 'sms' })
```
**Correct:**
```typescript
// Let user choose channel
const channels = ['sms', 'whatsapp'] as const
function MfaVerification({ factorId }) {
const [channel, setChannel] = useState<'sms' | 'whatsapp'>('sms')
const sendCode = () => {
supabase.auth.mfa.challenge({ factorId, channel })
}
return (
<>
<select onChange={(e) => setChannel(e.target.value)}>
<option value="sms">SMS</option>
<option value="whatsapp">WhatsApp</option>
</select>
<button onClick={sendCode}>Send Code</button>
</>
)
}
```
### 4. Phone Number Security Risks
**Issue:** Phone numbers can be recycled by carriers, leading to potential account takeover.
**Mitigation:**
```typescript
// Require additional verification for sensitive actions
// even with phone MFA
// Option 1: Also require email confirmation
await supabase.auth.reauthenticate()
// Option 2: Recommend TOTP as primary, phone as backup
const { data: factors } = await supabase.auth.mfa.listFactors()
if (factors.totp.length === 0) {
showRecommendation('Consider adding an authenticator app for better security')
}
```
## Provider Configuration
> **Note:** The phone messaging configuration for MFA is shared with phone auth login. The same provider settings are used for both.
### Twilio
Stop and ask the user to configure the phone provider in the Supabase Dashboard under Auth > Providers > Phone with the following settings:
```
Account SID: AC...
Auth Token: ...
Message Service SID: MG...
```
### Twilio Verify
Better deliverability, built-in rate limiting:
```
Account SID: AC...
Auth Token: ...
Verify Service SID: VA...
```
## Update Phone Number
```typescript
// User wants to change their MFA phone
// Step 1: Unenroll old factor
await supabase.auth.mfa.unenroll({ factorId: oldFactorId })
// Step 2: Enroll new phone
const { data } = await supabase.auth.mfa.enroll({
factorType: 'phone',
phone: '+1987654321',
})
// Step 3: Verify new phone
const { data: challenge } = await supabase.auth.mfa.challenge({
factorId: data.id,
channel: 'sms',
})
await supabase.auth.mfa.verify({
factorId: data.id,
challengeId: challenge.id,
code: verificationCode,
})
```
## Related
- [mfa-totp.md](mfa-totp.md) - TOTP authenticator MFA (recommended as primary)
- [Docs: Phone MFA](https://supabase.com/docs/guides/auth/auth-mfa/phone)

View File

@@ -0,0 +1,282 @@
---
title: Implement TOTP Multi-Factor Authentication
impact: HIGH
impactDescription: MFA significantly reduces account compromise risk
tags: auth, mfa, totp, 2fa, security, authenticator
---
## Implement TOTP Multi-Factor Authentication
Add Time-based One-Time Password (TOTP) authentication using apps like Google Authenticator, Authy, or 1Password.
## Authenticator Assurance Levels (AAL)
| Level | Meaning |
|-------|---------|
| `aal1` | Verified via conventional method (password, magic link, OAuth) |
| `aal2` | Additionally verified via second factor (TOTP, phone) |
## Enrollment Flow
### Step 1: Enroll Factor
```typescript
const { data, error } = await supabase.auth.mfa.enroll({
factorType: 'totp',
friendlyName: 'My Authenticator App',
})
if (error) throw error
const {
id: factorId, // Store this to complete verification
totp: {
qr_code, // Data URL for QR code image
secret, // Manual entry secret
uri, // otpauth:// URI
},
} = data
// Display QR code to user
// <img src={qr_code} alt="Scan with authenticator app" />
```
### Step 2: Verify Enrollment
```typescript
// Create a challenge
const { data: challenge, error } = await supabase.auth.mfa.challenge({
factorId,
})
if (error) throw error
// Verify with code from authenticator app
const { data, error: verifyError } = await supabase.auth.mfa.verify({
factorId,
challengeId: challenge.id,
code: '123456', // 6-digit code from app
})
if (!verifyError) {
// MFA enrolled successfully
// User now has aal2
}
```
## Sign-In with MFA
### Check if MFA Required
```typescript
const { data, error } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
const { currentLevel, nextLevel, currentAuthenticationMethods } = data
if (nextLevel === 'aal2' && currentLevel !== 'aal2') {
// User has MFA enrolled but hasn't verified this session
showMfaPrompt()
}
```
### Complete MFA Challenge
```typescript
// Get enrolled factors
const { data: { totp } } = await supabase.auth.mfa.listFactors()
const factor = totp[0]
// Create challenge
const { data: challenge } = await supabase.auth.mfa.challenge({
factorId: factor.id,
})
// Verify with user's code
const { error } = await supabase.auth.mfa.verify({
factorId: factor.id,
challengeId: challenge.id,
code: userInputCode,
})
if (!error) {
// User now has aal2 - redirect to app
router.push('/dashboard')
}
```
## Complete Example: Login Flow with MFA
```typescript
async function signIn(email: string, password: string, mfaCode?: string) {
// Step 1: Sign in with password
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) throw error
// Step 2: Check if MFA is required
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
if (aal.nextLevel === 'aal2' && aal.currentLevel !== 'aal2') {
if (!mfaCode) {
return { requiresMfa: true }
}
// Step 3: Complete MFA
const { data: { totp } } = await supabase.auth.mfa.listFactors()
const { data: challenge } = await supabase.auth.mfa.challenge({
factorId: totp[0].id,
})
const { error: mfaError } = await supabase.auth.mfa.verify({
factorId: totp[0].id,
challengeId: challenge.id,
code: mfaCode,
})
if (mfaError) throw mfaError
}
return { requiresMfa: false, user: data.user }
}
```
## Enforce MFA in RLS Policies
```sql
-- Require aal2 for all operations on sensitive data
create policy "Require MFA"
on sensitive_data
as restrictive
to authenticated
using ((select auth.jwt() ->> 'aal') = 'aal2');
-- Require MFA only for users who have enrolled
create policy "MFA for enrolled users"
on sensitive_data
as restrictive
to authenticated
using (
array[(select auth.jwt() ->> 'aal')] <@ (
select
case when count(id) > 0 then array['aal2']
else array['aal1', 'aal2']
end
from auth.mfa_factors
where ((select auth.uid()) = user_id) and status = 'verified'
)
);
```
## Unenroll Factor
```typescript
const { error } = await supabase.auth.mfa.unenroll({
factorId: 'd30fd651-184e-4748-a928-0a4b9be1d429',
})
```
## Common Mistakes
### 1. Not Checking AAL After Sign-In
**Incorrect:**
```typescript
const { data } = await supabase.auth.signInWithPassword({ email, password })
// Assumes user is fully authenticated
router.push('/dashboard')
```
**Correct:**
```typescript
const { data } = await supabase.auth.signInWithPassword({ email, password })
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
if (aal.nextLevel === 'aal2' && aal.currentLevel !== 'aal2') {
router.push('/mfa-verify')
} else {
router.push('/dashboard')
}
```
### 2. Missing RESTRICTIVE Keyword in RLS
**Incorrect:**
```sql
-- Other permissive policies can still grant access
create policy "Require MFA" on sensitive_data
using ((select auth.jwt() ->> 'aal') = 'aal2');
```
**Correct:**
```sql
-- RESTRICTIVE ensures this policy MUST pass
create policy "Require MFA" on sensitive_data
as restrictive
to authenticated
using ((select auth.jwt() ->> 'aal') = 'aal2');
```
### 3. Not Storing Factor ID During Enrollment
**Incorrect:**
```typescript
const { data } = await supabase.auth.mfa.enroll({ factorType: 'totp' })
// Show QR code but don't save factorId
showQRCode(data.totp.qr_code)
// Later: Can't verify - don't know the factorId
```
**Correct:**
```typescript
const { data } = await supabase.auth.mfa.enroll({ factorType: 'totp' })
const factorId = data.id // Save this
setFactorId(factorId)
showQRCode(data.totp.qr_code)
// Later: Use saved factorId
await supabase.auth.mfa.challenge({ factorId })
```
### 4. Not Handling Invalid Codes
**Incorrect:**
```typescript
await supabase.auth.mfa.verify({ factorId, challengeId, code })
// No error handling - user stuck if code wrong
```
**Correct:**
```typescript
const { error } = await supabase.auth.mfa.verify({
factorId,
challengeId,
code,
})
if (error) {
if (error.message.includes('invalid')) {
showError('Invalid code. Please try again.')
// Create new challenge for retry
const { data: newChallenge } = await supabase.auth.mfa.challenge({ factorId })
setChallengeId(newChallenge.id)
}
}
```
## Related
- [mfa-phone.md](mfa-phone.md) - Phone-based MFA
- [core-sessions.md](core-sessions.md) - Session management
- [Docs: MFA](https://supabase.com/docs/guides/auth/auth-mfa)

View File

@@ -0,0 +1,239 @@
---
title: Implement PKCE Flow for OAuth
impact: HIGH
impactDescription: PKCE prevents authorization code interception attacks in SPAs
tags: auth, oauth, pkce, spa, code-exchange, security
---
## Implement PKCE Flow for OAuth
Proof Key for Code Exchange (PKCE) secures OAuth in browser environments where client secrets can't be stored safely.
## How PKCE Works
1. Client generates a random `code_verifier`
2. Client creates `code_challenge` = SHA256(code_verifier)
3. Client sends `code_challenge` with auth request
4. After redirect, client exchanges code using `code_verifier`
5. Server verifies challenge matches, returns tokens
Supabase handles this automatically with `@supabase/ssr` or when using server-side code exchange.
## Server-Side Code Exchange (Recommended)
### Next.js App Router
```typescript
// app/auth/callback/route.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/'
if (code) {
const cookieStore = await cookies()
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
},
},
}
)
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
return NextResponse.redirect(`${origin}${next}`)
}
}
return NextResponse.redirect(`${origin}/auth/auth-code-error`)
}
```
### SvelteKit
```typescript
// src/routes/auth/callback/+server.ts
import { redirect } from '@sveltejs/kit'
export const GET = async ({ url, locals: { supabase } }) => {
const code = url.searchParams.get('code')
const next = url.searchParams.get('next') ?? '/'
if (code) {
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
throw redirect(303, next)
}
}
throw redirect(303, '/auth/auth-code-error')
}
```
## Initiating OAuth with PKCE
```typescript
// Client-side: initiate OAuth flow
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
// PKCE is automatic when using server-side code exchange
},
})
if (data.url) {
window.location.href = data.url
}
```
## Common Mistakes
### 1. Not Using Server-Side Code Exchange
**Incorrect:**
```typescript
// Client-side only - no server callback to exchange code securely
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
})
// Without a server callback route, session handling is less secure
```
**Correct:**
```typescript
// Use server-side callback to exchange code
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${origin}/auth/callback`, // Server route
},
})
// Server route exchanges code for session securely
```
### 2. Handling Code in Client Instead of Server
**Incorrect:**
```typescript
// Client-side callback page
useEffect(() => {
const code = new URLSearchParams(window.location.search).get('code')
if (code) {
// Don't do this on client - use server route instead
supabase.auth.exchangeCodeForSession(code)
}
}, [])
```
**Correct:**
```typescript
// Server-side callback route (see examples above)
export async function GET(request: Request) {
const code = new URL(request.url).searchParams.get('code')
if (code) {
await supabase.auth.exchangeCodeForSession(code)
}
}
```
### 3. Missing Cookie Configuration
**Incorrect:**
```typescript
// Missing cookie handlers - session not persisted
const supabase = createServerClient(url, key, {})
```
**Correct:**
```typescript
const supabase = createServerClient(url, key, {
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
},
},
})
```
### 4. Forgetting Error Handling
**Incorrect:**
```typescript
export async function GET(request: Request) {
const code = searchParams.get('code')
await supabase.auth.exchangeCodeForSession(code!) // Crashes if no code
return redirect('/dashboard')
}
```
**Correct:**
```typescript
export async function GET(request: Request) {
const code = searchParams.get('code')
if (!code) {
return redirect('/auth/error?message=missing_code')
}
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (error) {
console.error('Auth error:', error)
return redirect('/auth/error?message=exchange_failed')
}
return redirect('/dashboard')
}
```
## Skip Browser Redirect
For custom OAuth flows (e.g., popup windows):
```typescript
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
skipBrowserRedirect: true,
},
})
// Open in popup instead of redirect
const popup = window.open(data.url, 'oauth', 'width=500,height=600')
```
## Related
- [oauth-providers.md](oauth-providers.md) - Provider configuration
- [server-ssr.md](server-ssr.md) - Server-side auth setup
- [Docs: PKCE Flow](https://supabase.com/docs/guides/auth/sessions/pkce-flow)

View File

@@ -0,0 +1,252 @@
---
title: Configure OAuth Social Providers
impact: HIGH
impactDescription: Social login increases conversion - misconfiguration breaks authentication
tags: auth, oauth, social-login, google, github, apple, azure
---
## Configure OAuth Social Providers
Set up social authentication with Google, GitHub, Apple, and other providers.
## Quick Start
```typescript
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: 'https://yourapp.com/auth/callback',
},
})
// Redirects to Google, then back to your callback URL
```
## Supported Providers
| Provider | Key Notes |
|----------|-----------|
| Google | Most common, requires OAuth consent screen |
| GitHub | Great for developer apps |
| Apple | Required for iOS apps with social login |
| Azure/Microsoft | Enterprise, supports Entra ID |
| Discord | Gaming/community apps |
| Slack | Workspace apps |
| Twitter/X | Social apps |
| LinkedIn | Professional apps |
| Facebook | Social apps |
| Spotify | Music apps |
| Twitch | Streaming apps |
## Setup Steps (All Providers)
1. Stop and ask the user to **enable the provider** in the Supabase Dashboard under Auth > Providers
2. **Create OAuth app** at provider's developer console
3. **Set callback URL** on provider: `https://<project-ref>.supabase.co/auth/v1/callback`
4. Stop and ask the user to **add the Client ID and Secret** in the Supabase Dashboard under Auth > Providers
## Provider-Specific Configuration
### Google
```typescript
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
scopes: 'email profile',
queryParams: {
access_type: 'offline', // Get refresh token
prompt: 'consent', // Force consent screen
},
},
})
```
**Google Setup:**
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create OAuth 2.0 credentials (Web application)
3. Add authorized redirect URI: `https://<ref>.supabase.co/auth/v1/callback`
4. Configure OAuth consent screen
### GitHub
```typescript
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
scopes: 'read:user user:email',
},
})
```
**GitHub Setup:**
1. Go to GitHub Settings > Developer Settings > OAuth Apps
2. Create new OAuth App
3. Set callback URL: `https://<ref>.supabase.co/auth/v1/callback`
### Apple
```typescript
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'apple',
options: {
scopes: 'name email',
},
})
```
**Apple Setup:**
1. Requires Apple Developer Program membership
2. Register email sources for "Sign in with Apple for Email Communication"
3. Create App ID with "Sign in with Apple" capability
4. Create Service ID for web authentication, configure redirect URL
5. Generate private key (`.p8` file) for token signing
6. Stop and ask the user to add the Team ID, Service ID, and key in the Supabase Dashboard under Auth > Providers > Apple
**Important:**
- Apple only returns user's name on first sign-in. Store it immediately via `updateUser`.
- Apple requires generating a new secret key every 6 months using the `.p8` file. Set a calendar reminder — missed rotation breaks auth.
### Azure/Microsoft
```typescript
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'azure',
options: {
scopes: 'email',
},
})
```
## Common Mistakes
### 1. Wrong Callback URL
**Incorrect:**
```text
// In provider settings
https://yourapp.com/auth/callback
```
**Correct:**
```text
// Provider callback must be Supabase's callback endpoint
https://<project-ref>.supabase.co/auth/v1/callback
```
```typescript
// Your app's callback is set in redirectTo option
const { data } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: 'https://yourapp.com/auth/callback', // Your app
},
})
```
### 2. Missing Redirect URL in Allowlist
**Incorrect:**
```typescript
// redirectTo not in Supabase allowlist - fails silently
await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: 'http://localhost:3000/auth/callback',
},
})
```
**Correct:**
1. Stop and ask the user to add `http://localhost:3000/auth/callback` to the allowlist in the Supabase Dashboard under Auth > URL Configuration > Redirect URLs
2. Then use in code
### 3. Not Handling OAuth Callback
**Incorrect:**
```typescript
// /auth/callback page - no code handling
function CallbackPage() {
return <div>Loading...</div>
}
```
**Correct:**
```typescript
// /auth/callback page
import { useEffect } from 'react'
import { useRouter } from 'next/router'
function CallbackPage() {
const router = useRouter()
useEffect(() => {
// Supabase client auto-handles the hash fragment
// Just wait for session to be established
supabase.auth.onAuthStateChange((event, session) => {
if (event === 'SIGNED_IN' && session) {
router.push('/dashboard')
}
})
}, [])
return <div>Completing sign in...</div>
}
```
### 4. Not Requesting Email Scope
**Incorrect:**
```typescript
// GitHub doesn't return email by default
await supabase.auth.signInWithOAuth({ provider: 'github' })
// user.email may be null
```
**Correct:**
```typescript
await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
scopes: 'read:user user:email', // Explicitly request email
},
})
```
## Access Provider Tokens
```typescript
// Use onAuthStateChange to capture provider tokens after OAuth sign-in
supabase.auth.onAuthStateChange((event, session) => {
if (session?.provider_token) {
const providerToken = session.provider_token // Access token
const providerRefreshToken = session.provider_refresh_token
}
})
```
## Link Multiple Providers
Manual identity linking is in beta. Stop and ask the user to enable it in the Supabase Dashboard under Auth configuration, or set `GOTRUE_SECURITY_MANUAL_LINKING_ENABLED: true`.
```typescript
// User already signed in with email/password
// Link their Google account
const { data, error } = await supabase.auth.linkIdentity({
provider: 'google',
})
```
## Related
- [oauth-pkce.md](oauth-pkce.md) - PKCE flow for SPAs
- [Docs: Social Login](https://supabase.com/docs/guides/auth/social-login)
- [Docs: Google OAuth](https://supabase.com/docs/guides/auth/social-login/auth-google)

View File

@@ -0,0 +1,241 @@
---
title: Implement Magic Link Authentication
impact: MEDIUM-HIGH
impactDescription: Passwordless login improves UX and security - magic links are the most common approach
tags: auth, passwordless, magic-link, email, otp
---
## Implement Magic Link Authentication
Email-based passwordless authentication where users click a link to sign in.
## Quick Start
```typescript
// Send magic link
const { error } = await supabase.auth.signInWithOtp({
email: 'user@example.com',
options: {
emailRedirectTo: 'https://yourapp.com/auth/callback',
},
})
if (!error) {
showMessage('Check your email for the login link')
}
```
## PKCE Flow (Recommended)
For server-side apps, use PKCE with code exchange:
### Email Template (Auth > Email Templates > Magic Link in the Supabase Dashboard)
Stop and ask the user to update the Magic Link email template in the Supabase Dashboard under Auth > Email Templates > Magic Link with the following:
```html
<h2>Login to {{ .SiteURL }}</h2>
<p>Click the link below to sign in:</p>
<p><a href="{{ .RedirectTo }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">Log In</a></p>
```
> **Note:** Use `{{ .RedirectTo }}` instead of `{{ .SiteURL }}` when using the `redirectTo` option so the link respects custom redirect URLs.
### Server Callback Route
```typescript
// app/auth/confirm/route.ts (Next.js)
import { createServerClient } from '@supabase/ssr'
import { type NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const token_hash = searchParams.get('token_hash')
const type = searchParams.get('type')
const next = searchParams.get('next') ?? '/'
if (token_hash && type) {
const supabase = createServerClient(/* ... */)
const { error } = await supabase.auth.verifyOtp({
type: type as any,
token_hash,
})
if (!error) {
return NextResponse.redirect(new URL(next, request.url))
}
}
return NextResponse.redirect(new URL('/auth/error', request.url))
}
```
## Prevent Auto-Signup
Only allow existing users to sign in:
```typescript
const { error } = await supabase.auth.signInWithOtp({
email: 'user@example.com',
options: {
shouldCreateUser: false, // Don't create new users
},
})
if (error?.message.includes('Signups not allowed')) {
showError('No account found with this email')
}
```
## Common Mistakes
### 1. Email Client Prefetching Consuming Links
**Problem:** Email security scanners and preview features click links, consuming them before users do.
**Incorrect:**
```html
<!-- Direct link in email - may be consumed by scanner -->
<a href="{{ .ConfirmationURL }}">Log In</a>
```
**Correct - Option 1: Use confirmation page**
```html
<!-- Link to confirmation page that requires user action -->
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">
Log In
</a>
```
Then on `/auth/confirm`, show a button that triggers verification:
```typescript
function ConfirmPage() {
const handleConfirm = async () => {
const params = new URLSearchParams(window.location.search)
await supabase.auth.verifyOtp({
token_hash: params.get('token_hash')!,
type: 'email',
})
}
return <button onClick={handleConfirm}>Confirm Login</button>
}
```
**Correct - Option 2: Use OTP codes instead**
```typescript
// Send 6-digit code instead of link
const { error } = await supabase.auth.signInWithOtp({
email: 'user@example.com',
options: {
shouldCreateUser: false,
},
})
```
Modify email template to show code:
```html
<p>Your login code is: <strong>{{ .Token }}</strong></p>
```
### 2. Missing Redirect URL Configuration
**Incorrect:**
```typescript
await supabase.auth.signInWithOtp({
email: 'user@example.com',
options: {
emailRedirectTo: 'https://myapp.com/dashboard',
},
})
// Redirect URL not in allowlist - defaults to site URL
```
**Correct:**
1. Stop and ask the user to add the redirect URL to the allowlist in the Supabase Dashboard under Auth > URL Configuration > Redirect URLs
2. Then use in code:
```typescript
await supabase.auth.signInWithOtp({
email: 'user@example.com',
options: {
emailRedirectTo: 'https://myapp.com/dashboard', // Must be in allowlist
},
})
```
### 3. Not Handling Token Expiration
**Incorrect:**
```typescript
// User clicks old link
const { error } = await supabase.auth.verifyOtp({ token_hash, type: 'email' })
// Error handling missing
```
**Correct:**
```typescript
const { error } = await supabase.auth.verifyOtp({ token_hash, type: 'email' })
if (error) {
if (error.message.includes('expired')) {
showError('This link has expired. Please request a new one.')
showResendButton()
} else {
showError('Unable to verify. Please try again.')
}
}
```
### 4. Implicit Flow in SSR Apps
**Incorrect:**
```typescript
// Server-side - implicit flow puts tokens in URL hash
// Server can't read hash fragments
await supabase.auth.signInWithOtp({ email })
```
**Correct:**
```typescript
// Use PKCE flow with token_hash in query params
// See "PKCE Flow" section above
```
## Rate Limits
| Limit | Default |
|-------|---------|
| Per email | 1 per 60 seconds |
| Link expiry | Configurable in the Supabase Dashboard |
## Customizing Email Template
Stop and ask the user to customize the email template in the Supabase Dashboard under Auth > Email Templates > Magic Link.
Variables available:
- `{{ .Token }}` - 6-digit OTP code
- `{{ .TokenHash }}` - Hashed version of the token
- `{{ .SiteURL }}` - Your app's configured Site URL
- `{{ .RedirectTo }}` - The redirect URL passed during auth operations
- `{{ .ConfirmationURL }}` - Full confirmation URL
- `{{ .Data }}` - User metadata from `auth.users.user_metadata`
- `{{ .Email }}` - The user's email address
## Related
- [passwordless-otp.md](passwordless-otp.md) - Email/Phone OTP codes
- [core-sessions.md](core-sessions.md) - Session management after login
- [Docs: Magic Links](https://supabase.com/docs/guides/auth/auth-email-passwordless)

View File

@@ -0,0 +1,252 @@
---
title: Implement Email and Phone OTP
impact: MEDIUM-HIGH
impactDescription: OTP codes avoid link prefetching issues and work better for mobile apps
tags: auth, passwordless, otp, email, phone, sms
---
## Implement Email and Phone OTP
One-time password authentication via email or SMS. Better than magic links when link prefetching is an issue or for mobile apps.
## Email OTP
### Step 1: Send OTP
```typescript
const { error } = await supabase.auth.signInWithOtp({
email: 'user@example.com',
options: {
shouldCreateUser: true, // or false to only allow existing users
},
})
if (!error) {
showMessage('Check your email for the verification code')
}
```
### Step 2: Verify OTP
```typescript
const { data, error } = await supabase.auth.verifyOtp({
email: 'user@example.com',
token: '123456', // 6-digit code from email
type: 'email',
})
if (!error) {
// User is now signed in
console.log('Signed in:', data.user)
}
```
### Configure Email Template
Stop and ask the user to update the email template in the Supabase Dashboard under Auth > Email Templates > Magic Link.
Include `{{ .Token }}` to show the OTP code:
```html
<h2>Your verification code</h2>
<p>Enter this code to sign in:</p>
<p style="font-size: 32px; font-weight: bold; letter-spacing: 4px;">
{{ .Token }}
</p>
<p>This code will expire shortly.</p>
```
## Phone OTP
### Step 1: Send OTP
```typescript
const { error } = await supabase.auth.signInWithOtp({
phone: '+1234567890',
options: {
channel: 'sms', // or 'whatsapp' (requires Twilio WhatsApp sender)
},
})
if (!error) {
showMessage('Check your phone for the verification code')
}
```
> **Note:** The user receives a 6-digit PIN that must be verified within 60 seconds.
### Step 2: Verify OTP
```typescript
const { data, error } = await supabase.auth.verifyOtp({
phone: '+1234567890',
token: '123456',
type: 'sms',
})
```
## OTP Types
| Type | Use Case |
|------|----------|
| `email` | Email OTP sign-in |
| `sms` | Phone OTP sign-in |
| `phone_change` | Verify new phone number |
| `email_change` | Verify new email address |
| `signup` | Verify email during sign-up |
| `recovery` | Password recovery |
## Common Mistakes
### 1. Using Wrong Type Parameter
**Incorrect:**
```typescript
// Phone OTP with wrong type
await supabase.auth.verifyOtp({
phone: '+1234567890',
token: '123456',
type: 'email', // Wrong!
})
```
**Correct:**
```typescript
await supabase.auth.verifyOtp({
phone: '+1234567890',
token: '123456',
type: 'sms', // Correct for phone
})
```
### 2. Not Handling Expiration
**Incorrect:**
```typescript
const { error } = await supabase.auth.verifyOtp({ email, token, type: 'email' })
if (error) {
showError('Verification failed') // Not helpful
}
```
**Correct:**
```typescript
const { error } = await supabase.auth.verifyOtp({ email, token, type: 'email' })
if (error) {
if (error.message.includes('expired')) {
showError('Code expired. Please request a new one.')
showResendOption()
} else if (error.message.includes('invalid')) {
showError('Invalid code. Please check and try again.')
} else {
showError('Verification failed. Please try again.')
}
}
```
### 3. Not Implementing Resend Logic
**Incorrect:**
```typescript
// No way to get new code
function OtpInput() {
return <input placeholder="Enter code" />
}
```
**Correct:**
```typescript
function OtpInput({ email }) {
const [cooldown, setCooldown] = useState(0)
useEffect(() => {
const timer = cooldown > 0 && setInterval(() => setCooldown(c => c - 1), 1000)
return () => clearInterval(timer)
}, [cooldown])
const handleResend = async () => {
await supabase.auth.signInWithOtp({ email })
setCooldown(60) // 60 second cooldown
}
return (
<>
<input placeholder="Enter 6-digit code" />
<button onClick={handleResend} disabled={cooldown > 0}>
{cooldown > 0 ? `Resend in ${cooldown}s` : 'Resend code'}
</button>
</>
)
}
```
### 4. Storing Email/Phone Inconsistently
**Incorrect:**
```typescript
// Send to one format
await supabase.auth.signInWithOtp({ email: 'User@Example.com' })
// Verify with different format
await supabase.auth.verifyOtp({
email: 'user@example.com', // Different case
token: '123456',
type: 'email',
})
```
**Correct:**
```typescript
const email = userInput.toLowerCase().trim()
// Use consistently
await supabase.auth.signInWithOtp({ email })
await supabase.auth.verifyOtp({ email, token, type: 'email' })
```
## Phone Number Format
Always use E.164 format:
```typescript
// Correct formats
'+14155551234'
'+442071234567'
// Incorrect formats
'(415) 555-1234'
'415-555-1234'
'00447021234567'
```
Consider using a library like `libphonenumber`:
```typescript
import { parsePhoneNumber } from 'libphonenumber-js'
const phoneNumber = parsePhoneNumber(userInput, 'US')
const e164 = phoneNumber?.format('E.164') // '+14155551234'
```
## Rate Limits
| Limit | Default |
|-------|---------|
| Email OTP | 1 per 60 seconds |
| Phone OTP | 1 per 60 seconds |
| OTP expiry | Configurable (max 86400 seconds / 24 hours) |
## Related
- [passwordless-magic-links.md](passwordless-magic-links.md) - Magic link alternative
- [mfa-phone.md](mfa-phone.md) - Phone as second factor
- [Docs: Phone Auth](https://supabase.com/docs/guides/auth/phone-login)

View File

@@ -0,0 +1,273 @@
---
title: Use Admin Auth API Securely
impact: CRITICAL
impactDescription: Admin API bypasses RLS - misuse exposes all data and enables account takeover
tags: auth, admin, service-role, server, security, user-management
---
## Use Admin Auth API Securely
The Admin Auth API (`supabase.auth.admin.*`) uses the service role key to bypass RLS for user management operations.
## When to Use
- Create users programmatically (invitations, imports)
- Delete users (admin panel, GDPR requests)
- Update user metadata that users can't modify (`app_metadata`)
- List all users (admin dashboards)
- Generate magic links server-side
## Setup
> **Key model transition:** Supabase is transitioning from `anon`/`service_role` JWT keys to `sb_publishable_xxx`/`sb_secret_xxx` keys. Both formats work. See [API Keys](https://supabase.com/docs/guides/api/api-keys) for details.
```typescript
import { createClient } from '@supabase/supabase-js'
// NEVER expose serviceRoleKey to client-side code
const supabaseAdmin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
}
)
```
## Common Operations
### Create User
```typescript
const { data, error } = await supabaseAdmin.auth.admin.createUser({
email: 'newuser@example.com',
password: 'securepassword123',
email_confirm: true, // Skip email verification
user_metadata: {
full_name: 'John Doe',
},
app_metadata: {
role: 'premium',
organization_id: 'org-uuid',
},
})
```
### Delete User
```typescript
const { error } = await supabaseAdmin.auth.admin.deleteUser(userId)
```
### Update User (Admin)
```typescript
const { data, error } = await supabaseAdmin.auth.admin.updateUserById(userId, {
email: 'newemail@example.com',
email_confirm: true, // Confirm without sending email
app_metadata: {
role: 'admin',
},
ban_duration: 'none', // Unban user
})
```
### List Users
```typescript
const { data: { users }, error } = await supabaseAdmin.auth.admin.listUsers({
page: 1,
perPage: 50,
})
```
### Generate Magic Link
```typescript
const { data, error } = await supabaseAdmin.auth.admin.generateLink({
type: 'magiclink',
email: 'user@example.com',
options: {
redirectTo: 'https://yourapp.com/dashboard',
},
})
// data.properties.action_link contains the magic link
// Send via your own email system
```
### Invite User
```typescript
const { data, error } = await supabaseAdmin.auth.admin.inviteUserByEmail(
'newuser@example.com',
{
redirectTo: 'https://yourapp.com/set-password',
data: {
invited_by: adminUserId,
role: 'team_member',
},
}
)
```
## Common Mistakes
### 1. Exposing Service Role Key to Client
**Incorrect:**
```typescript
// Client-side code
const supabase = createClient(url, process.env.NEXT_PUBLIC_SERVICE_ROLE_KEY!)
// DANGER: Key exposed in browser, anyone can access all data
```
**Correct:**
```typescript
// Server-side only (API route, server action)
const supabaseAdmin = createClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY!)
// Key never sent to browser
```
### 2. Using Admin Client for Regular Operations
**Incorrect:**
```typescript
// Server-side route
async function getUserPosts(userId: string) {
// Using admin client when regular client would work
const { data } = await supabaseAdmin.from('posts').select('*').eq('user_id', userId)
return data
}
// Bypasses RLS unnecessarily - any bug could leak data
```
**Correct:**
```typescript
// Use user's authenticated client when possible
async function getUserPosts(req: Request) {
const supabase = createServerClient(/* with user's session */)
// RLS ensures user only sees their own posts
const { data } = await supabase.from('posts').select('*')
return data
}
```
### 3. Not Validating Admin Actions
**Incorrect:**
```typescript
// API route
export async function DELETE(req: Request) {
const { userId } = await req.json()
// No authorization check - anyone can delete any user!
await supabaseAdmin.auth.admin.deleteUser(userId)
}
```
**Correct:**
```typescript
export async function DELETE(req: Request) {
// Verify the requester is an admin
const supabase = createServerClient(/* ... */)
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return new Response('Unauthorized', { status: 401 })
}
// Check admin status
const { data: admin } = await supabaseAdmin
.from('admins')
.select('id')
.eq('user_id', user.id)
.single()
if (!admin) {
return new Response('Forbidden', { status: 403 })
}
// Now safe to perform admin action
const { userId } = await req.json()
await supabaseAdmin.auth.admin.deleteUser(userId)
}
```
### 4. Admin Client with User Session
**Incorrect:**
```typescript
// SSR: Creating admin client but also reading cookies
const supabaseAdmin = createServerClient(url, serviceRoleKey, {
cookies: {
getAll() { return cookieStore.getAll() },
// ...
},
})
// If user session exists, it uses user's permissions, NOT service role!
```
**Correct:**
```typescript
// Admin client should NOT use cookies
const supabaseAdmin = createClient(url, serviceRoleKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
})
// Separate user client for auth checking
const supabaseUser = createServerClient(url, anonKey, {
cookies: { /* ... */ },
})
```
### 5. Hardcoding Service Role Key
**Incorrect:**
```typescript
const supabaseAdmin = createClient(url, 'eyJhbGciOiJIUzI1NiIs...')
// Key in source code - leaked in version control
```
**Correct:**
```typescript
const supabaseAdmin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
)
// Key in environment variables
```
## Admin API Methods
| Method | Purpose |
|--------|---------|
| `createUser` | Create new user with optional auto-confirm |
| `deleteUser` | Permanently delete user |
| `updateUserById` | Update any user field including app_metadata |
| `listUsers` | Paginated list of all users |
| `getUserById` | Get specific user details |
| `inviteUserByEmail` | Send invitation email |
| `generateLink` | Create magic/recovery/invite links |
## Related
- [server-ssr.md](server-ssr.md) - Server-side auth for user operations
- [hooks-custom-claims.md](hooks-custom-claims.md) - Adding custom claims
- [../db/security-service-role.md](../db/security-service-role.md) - Service role security
- [Docs: Admin API](https://supabase.com/docs/reference/javascript/auth-admin-createuser)

View File

@@ -0,0 +1,373 @@
---
title: Implement SSR Authentication
impact: CRITICAL
impactDescription: SSR auth mistakes cause auth failures, security issues, and hydration mismatches
tags: auth, ssr, server, nextjs, sveltekit, nuxt, cookies
---
## Implement SSR Authentication
Server-side rendering authentication using `@supabase/ssr` for Next.js, SvelteKit, Nuxt, and other frameworks.
## Install
```bash
npm install @supabase/ssr @supabase/supabase-js
```
## Next.js App Router
### Client Setup
```typescript
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
)
}
```
### Server Client Setup
```typescript
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Ignore in Server Components
}
},
},
}
)
}
```
### Middleware
```typescript
// proxy.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
)
supabaseResponse = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// Refresh session if needed
const { data: claims } = await supabase.auth.getClaims()
// Protect routes
if (!claims && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return supabaseResponse
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
```
### Usage in Server Components
```typescript
// app/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const supabase = await createClient()
// Use getClaims() (validates JWT locally) or getUser() (round-trips to Auth server)
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
const { data: posts } = await supabase
.from('posts')
.select('*')
.eq('user_id', user.id)
return <div>{/* Render posts */}</div>
}
```
## SvelteKit
### Hooks Setup
```typescript
// src/hooks.server.ts
import { createServerClient } from '@supabase/ssr'
import type { Handle } from '@sveltejs/kit'
export const handle: Handle = async ({ event, resolve }) => {
event.locals.supabase = createServerClient(
import.meta.env.PUBLIC_SUPABASE_URL,
import.meta.env.PUBLIC_SUPABASE_PUBLISHABLE_KEY,
{
cookies: {
getAll() {
return event.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
event.cookies.set(name, value, { ...options, path: '/' })
})
},
},
}
)
event.locals.safeGetSession = async () => {
const { data: { session } } = await event.locals.supabase.auth.getSession()
if (!session) return { session: null, user: null }
const { data: { user }, error } = await event.locals.supabase.auth.getUser()
if (error) return { session: null, user: null }
return { session, user }
}
return resolve(event, {
filterSerializedResponseHeaders(name) {
return name === 'content-range' || name === 'x-supabase-api-version'
},
})
}
```
## Common Mistakes
### 1. Using createClient Instead of createServerClient
**Incorrect:**
```typescript
// Server-side code
import { createClient } from '@supabase/supabase-js'
export async function GET() {
const supabase = createClient(url, key)
// No cookie handling - session not available
const { data: { user } } = await supabase.auth.getUser()
}
```
**Correct:**
```typescript
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function GET() {
const cookieStore = await cookies()
const supabase = createServerClient(url, key, {
cookies: {
getAll() { return cookieStore.getAll() },
setAll(cookiesToSet) { /* ... */ },
},
})
const { data: { user } } = await supabase.auth.getUser()
}
```
### 2. Using getSession() for Auth Validation
**Incorrect:**
```typescript
// Server-side - INSECURE
const { data: { session } } = await supabase.auth.getSession()
if (session) {
// Session from cookies - not validated!
return protectedContent
}
```
**Correct:**
```typescript
// Server-side - validates JWT
const { data: { user }, error } = await supabase.auth.getUser()
if (!user || error) {
redirect('/login')
}
return protectedContent
```
### 3. Missing Cookie Configuration
**Incorrect:**
```typescript
const supabase = createServerClient(url, key, {})
// No cookie handlers - session lost on page reload
```
**Correct:**
```typescript
const supabase = createServerClient(url, key, {
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
},
},
})
```
### 4. Not Refreshing Session in Middleware
**Incorrect:**
```typescript
// proxy.ts
export async function middleware(request: NextRequest) {
// No supabase call - session never refreshed
return NextResponse.next()
}
```
**Correct:**
```typescript
export async function middleware(request: NextRequest) {
// Create client to trigger cookie refresh
const supabase = createServerClient(url, key, {
cookies: { /* ... */ },
})
// This refreshes the session if needed
await supabase.auth.getClaims()
return supabaseResponse
}
```
### 5. Hydration Mismatch with Auth State
**Incorrect:**
```typescript
// Client component
export function Header() {
const [user, setUser] = useState(null)
useEffect(() => {
supabase.auth.getUser().then(({ data }) => setUser(data.user))
}, [])
// Server renders null, client renders user = mismatch
return <div>{user?.email}</div>
}
```
**Correct:**
```typescript
// Pass user from server component
// app/layout.tsx (Server Component)
export default async function Layout({ children }) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
return <Header initialUser={user}>{children}</Header>
}
// components/Header.tsx (Client Component)
'use client'
export function Header({ initialUser }) {
const [user, setUser] = useState(initialUser)
useEffect(() => {
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => setUser(session?.user ?? null)
)
return () => subscription.unsubscribe()
}, [])
return <div>{user?.email}</div>
}
```
## OAuth Callback Route
```typescript
// app/auth/callback/route.ts
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/'
if (code) {
const supabase = await createClient()
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
return NextResponse.redirect(`${origin}${next}`)
}
}
return NextResponse.redirect(`${origin}/auth/auth-code-error`)
}
```
## Related
- [oauth-pkce.md](oauth-pkce.md) - PKCE flow for OAuth
- [core-sessions.md](core-sessions.md) - Session management
- [server-admin-api.md](server-admin-api.md) - Admin operations
- [Docs: SSR](https://supabase.com/docs/guides/auth/server-side)

View File

@@ -0,0 +1,225 @@
---
title: Configure SAML 2.0 SSO
impact: MEDIUM
impactDescription: Enterprise SSO enables organizations to use their identity providers
tags: auth, sso, saml, enterprise, okta, azure-ad, identity-provider
---
## Configure SAML 2.0 SSO
Set up enterprise Single Sign-On with SAML 2.0 identity providers (Okta, Azure AD, Google Workspaces, etc.).
> **Prerequisite:** SAML 2.0 support is disabled by default. Stop and ask the user to enable it on the Auth Providers page in the Supabase Dashboard.
## Key SAML Information
Provide these values to your Identity Provider:
| Setting | Value |
|---------|-------|
| Entity ID | `https://<project-ref>.supabase.co/auth/v1/sso/saml/metadata` |
| ACS URL | `https://<project-ref>.supabase.co/auth/v1/sso/saml/acs` |
| Metadata URL | `https://<project-ref>.supabase.co/auth/v1/sso/saml/metadata` |
| NameID Format | `emailAddress` or `persistent` |
## Sign In with SSO
```typescript
// Sign in using domain
const { data, error } = await supabase.auth.signInWithSSO({
domain: 'company.com',
})
if (data?.url) {
window.location.href = data.url
}
// Or sign in using provider ID (if you have multiple IdPs for same domain)
const { data, error } = await supabase.auth.signInWithSSO({
providerId: '21648a9d-8d5a-4555-a9d1-d6375dc14e92',
})
```
## CLI Setup
### Add SAML Provider
```bash
# From IdP metadata URL
supabase sso add \
--type saml \
--project-ref your-project-ref \
--metadata-url 'https://idp.company.com/saml/metadata' \
--domains company.com,subsidiary.com
# From metadata file
supabase sso add \
--type saml \
--project-ref your-project-ref \
--metadata-file ./idp-metadata.xml \
--domains company.com
```
### List Providers
```bash
supabase sso list --project-ref your-project-ref
```
### Update Provider
```bash
# Update domains
supabase sso update <provider-id> \
--project-ref your-project-ref \
--domains company.com,newdomain.com
# Update attribute mapping
supabase sso update <provider-id> \
--project-ref your-project-ref \
--attribute-mapping-file ./mapping.json
```
### Remove Provider
```bash
supabase sso remove <provider-id> --project-ref your-project-ref
```
## Attribute Mapping
Map IdP attributes to Supabase user fields:
```json
{
"keys": {
"email": {
"name": "mail",
"default": ""
},
"first_name": {
"name": "givenName"
},
"last_name": {
"name": "surname"
},
"department": {
"name": "department",
"default": "Unknown"
}
}
}
```
Apply mapping:
```bash
supabase sso update <provider-id> \
--project-ref your-project-ref \
--attribute-mapping-file ./mapping.json
```
## Common Mistakes
### 1. Wrong NameID Format
**Incorrect:**
```text
NameID Format: transient
// User gets new ID on each login - can't track users
```
**Correct:**
```text
NameID Format: emailAddress
// Or: persistent (stable identifier)
```
### 2. Not Linking SSO Users to Existing Accounts
**Issue:** SSO users are NOT automatically linked to existing accounts with the same email. They become separate users.
**Solution:** If you need to link accounts, implement a manual linking flow after SSO sign-in.
### 3. Missing Attribute Mapping
**Incorrect:**
```json
// No mapping - attributes not captured
{}
```
**Correct:**
```json
{
"keys": {
"email": { "name": "mail" },
"first_name": { "name": "givenName" },
"last_name": { "name": "sn" }
}
}
```
### 4. Not Restricting Access by SSO Provider
**Incorrect:**
```sql
-- Any authenticated user can access
create policy "View settings" on org_settings
using (auth.uid() is not null);
```
**Correct:**
```sql
-- Only users from specific SSO provider can access
create policy "SSO users view settings" on org_settings
as restrictive
using (
(select auth.jwt() #>> '{amr,0,provider}') = 'sso-provider-id'
);
```
> **MFA caveat:** If MFA is enabled with SSO, the `amr` array may have a different method at index `0`. Check all entries rather than only `amr[0]`.
## Provider-Specific Setup
### Okta
1. Create SAML 2.0 app in Okta Admin
2. Single Sign-On URL: `https://<ref>.supabase.co/auth/v1/sso/saml/acs`
3. Audience URI: `https://<ref>.supabase.co/auth/v1/sso/saml/metadata`
4. Download metadata and add via CLI
### Azure AD / Entra ID
1. Create Enterprise Application > Non-gallery application
2. Set up Single Sign-On > SAML
3. Basic SAML Configuration:
- Identifier: `https://<ref>.supabase.co/auth/v1/sso/saml/metadata`
- Reply URL: `https://<ref>.supabase.co/auth/v1/sso/saml/acs`
4. Download Federation Metadata XML
### Google Workspaces
1. Admin Console > Apps > Web and mobile apps > Add app > Add custom SAML app
2. ACS URL: `https://<ref>.supabase.co/auth/v1/sso/saml/acs`
3. Entity ID: `https://<ref>.supabase.co/auth/v1/sso/saml/metadata`
4. Download IdP metadata
## Pricing
Available on Pro plan and above. Pro and Team plans include 50 SSO MAUs before overage charges apply.
For pricing information regarding SSO MAU, fetch https://supabase.com/docs/guides/platform/manage-your-usage/monthly-active-users-sso
## Related
- [oauth-providers.md](oauth-providers.md) - Social OAuth providers
- [hooks-custom-claims.md](hooks-custom-claims.md) - Add custom claims from SSO attributes
- [Docs: SAML SSO](https://supabase.com/docs/guides/auth/enterprise-sso/auth-sso-saml)