mirror of
https://github.com/supabase/agent-skills.git
synced 2026-03-27 10:09:26 +08:00
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:
committed by
Pedro Rodrigues
parent
0a5b9bfd14
commit
51435f97a3
@@ -26,16 +26,34 @@ supabase/
|
|||||||
|
|
||||||
| Priority | Category | Impact | Prefix |
|
| Priority | Category | Impact | Prefix |
|
||||||
|----------|----------|--------|--------|
|
|----------|----------|--------|--------|
|
||||||
| 1 | Database | CRITICAL | `db-` |
|
| 1 | Authentication | CRITICAL | `auth-` |
|
||||||
| 2 | Edge Functions | HIGH | `edge-` |
|
| 2 | Database | CRITICAL | `db-` |
|
||||||
| 3 | SDK | HIGH | `sdk-` |
|
| 3 | Edge Functions | HIGH | `edge-` |
|
||||||
| 4 | Realtime | MEDIUM-HIGH | `realtime-` |
|
| 4 | SDK | HIGH | `sdk-` |
|
||||||
| 5 | Storage | HIGH | `storage-` |
|
| 5 | Realtime | MEDIUM-HIGH | `realtime-` |
|
||||||
|
| 6 | Storage | HIGH | `storage-` |
|
||||||
|
|
||||||
Reference files are named `{prefix}-{topic}.md` (e.g., `query-missing-indexes.md`).
|
Reference files are named `{prefix}-{topic}.md` (e.g., `query-missing-indexes.md`).
|
||||||
|
|
||||||
## Available References
|
## 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-`):
|
**Database** (`db-`):
|
||||||
- `references/db-conn-pooling.md`
|
- `references/db-conn-pooling.md`
|
||||||
- `references/db-migrations-diff.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*
|
||||||
@@ -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:
|
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
|
### Database
|
||||||
|
|
||||||
| Area | Resource | When to Use |
|
| Area | Resource | When to Use |
|
||||||
|
|||||||
@@ -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
|
**Impact:** CRITICAL
|
||||||
**Description:** Row Level Security policies, connection pooling, schema design patterns, migrations, performance optimization, and security functions for Supabase Postgres.
|
**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
|
**Impact:** HIGH
|
||||||
**Description:** Fundamentals, authentication, database access, CORS, routing, error handling, streaming, WebSockets, regional invocations, testing, and limits.
|
**Description:** Fundamentals, authentication, database access, CORS, routing, error handling, streaming, WebSockets, regional invocations, testing, and limits.
|
||||||
|
|
||||||
## 3. SDK (sdk)
|
## 4. SDK (sdk)
|
||||||
|
|
||||||
**Impact:** HIGH
|
**Impact:** HIGH
|
||||||
**Description:** supabase-js client initialization, TypeScript generation, CRUD queries, filters, joins, RPC calls, error handling, performance, and Next.js integration.
|
**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
|
**Impact:** MEDIUM-HIGH
|
||||||
**Description:** Channel setup, Broadcast messaging, Presence tracking, Postgres Changes listeners, cleanup patterns, error handling, and debugging.
|
**Description:** Channel setup, Broadcast messaging, Presence tracking, Postgres Changes listeners, cleanup patterns, error handling, and debugging.
|
||||||
|
|
||||||
## 5. Storage (storage)
|
## 6. Storage (storage)
|
||||||
|
|
||||||
**Impact:** HIGH
|
**Impact:** HIGH
|
||||||
**Description:** File uploads (standard and resumable), downloads, signed URLs, image transformations, CDN caching, access control with RLS policies, and file management operations.
|
**Description:** File uploads (standard and resumable), downloads, signed URLs, image transformations, CDN caching, access control with RLS policies, and file management operations.
|
||||||
|
|||||||
174
skills/supabase/references/auth-core-sessions.md
Normal file
174
skills/supabase/references/auth-core-sessions.md
Normal 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)
|
||||||
179
skills/supabase/references/auth-core-signin.md
Normal file
179
skills/supabase/references/auth-core-signin.md
Normal 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)
|
||||||
178
skills/supabase/references/auth-core-signup.md
Normal file
178
skills/supabase/references/auth-core-signup.md
Normal 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)
|
||||||
285
skills/supabase/references/auth-hooks-custom-claims.md
Normal file
285
skills/supabase/references/auth-hooks-custom-claims.md
Normal 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)
|
||||||
287
skills/supabase/references/auth-hooks-send-email-http.md
Normal file
287
skills/supabase/references/auth-hooks-send-email-http.md
Normal 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)
|
||||||
285
skills/supabase/references/auth-hooks-send-email-sql.md
Normal file
285
skills/supabase/references/auth-hooks-send-email-sql.md
Normal 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)
|
||||||
256
skills/supabase/references/auth-mfa-phone.md
Normal file
256
skills/supabase/references/auth-mfa-phone.md
Normal 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)
|
||||||
282
skills/supabase/references/auth-mfa-totp.md
Normal file
282
skills/supabase/references/auth-mfa-totp.md
Normal 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)
|
||||||
239
skills/supabase/references/auth-oauth-pkce.md
Normal file
239
skills/supabase/references/auth-oauth-pkce.md
Normal 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)
|
||||||
252
skills/supabase/references/auth-oauth-providers.md
Normal file
252
skills/supabase/references/auth-oauth-providers.md
Normal 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)
|
||||||
241
skills/supabase/references/auth-passwordless-magic-links.md
Normal file
241
skills/supabase/references/auth-passwordless-magic-links.md
Normal 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)
|
||||||
252
skills/supabase/references/auth-passwordless-otp.md
Normal file
252
skills/supabase/references/auth-passwordless-otp.md
Normal 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)
|
||||||
273
skills/supabase/references/auth-server-admin-api.md
Normal file
273
skills/supabase/references/auth-server-admin-api.md
Normal 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)
|
||||||
373
skills/supabase/references/auth-server-ssr.md
Normal file
373
skills/supabase/references/auth-server-ssr.md
Normal 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)
|
||||||
225
skills/supabase/references/auth-sso-saml.md
Normal file
225
skills/supabase/references/auth-sso-saml.md
Normal 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)
|
||||||
Reference in New Issue
Block a user