modern-auth-2026
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseModern Authentication Expert (2026)
2026年现代认证专家指南
Master passwordless-first authentication with passkeys, OAuth, magic links, and cross-device sync for modern web and mobile applications.
掌握基于passkeys、OAuth、魔法链接和跨设备同步的无密码优先认证方案,适用于现代Web和移动应用。
When to Use
适用场景
✅ USE this skill for:
- Implementing passkeys/WebAuthn authentication
- Google and Apple OAuth social login
- Supabase Auth configuration and troubleshooting
- Magic link/OTP passwordless flows
- Cross-device authentication sync
- MFA implementation (TOTP, passkeys as 2FA)
- Email/SMS recovery flows
- App Store compliance for social login
❌ DO NOT use for:
- Session management without auth context → use standard JWT patterns
- Authorization/RBAC policies → use skill
security-auditor - API key management → use skill
api-architect - Supabase RLS policies → use skill
supabase-admin
✅ 适用于以下场景:
- 实现passkeys/WebAuthn认证
- Google和Apple OAuth社交登录
- Supabase Auth配置与故障排查
- 魔法链接/OTP无密码流程
- 跨设备认证同步
- MFA实现(TOTP、将passkeys作为双因素认证)
- 邮箱/短信恢复流程
- 社交登录的App Store合规适配
❌ 不适用于以下场景:
- 无认证上下文的会话管理 → 请使用标准JWT模式
- 授权/RBAC策略 → 请使用技能
security-auditor - API密钥管理 → 请使用技能
api-architect - Supabase RLS策略 → 请使用技能
supabase-admin
2026 Authentication Landscape
2026年认证领域现状
Industry Adoption Stats
行业采用数据
- Passkeys: 87% of US/UK companies now use passkeys (FIDO Alliance)
- Google: 800+ million accounts use passkeys
- Amazon: 175 million users created passkeys in first year
- Trend: Passwordless is the security baseline, not a luxury
- Passkeys: 87%的美英企业已采用passkeys(FIDO联盟数据)
- Google: 超8亿账户使用passkeys
- Amazon: 第一年就有1.75亿用户创建了passkeys
- 趋势: 无密码认证已成为安全基线,而非高端功能
Key Standards
核心标准
| Standard | Purpose | Status |
|---|---|---|
| WebAuthn L2 | Browser passkey API | Fully supported |
| FIDO2/CTAP2 | Cross-platform passkeys | Mature |
| OAuth 2.1 | Simplified OAuth | Replacing 2.0 |
| OAuth3 | Short-lived tokens | Emerging |
| Passkey Sync | iCloud/Google sync | Production |
| 标准 | 用途 | 状态 |
|---|---|---|
| WebAuthn L2 | 浏览器passkey API | 完全支持 |
| FIDO2/CTAP2 | 跨平台passkeys | 成熟可用 |
| OAuth 2.1 | 简化版OAuth | 逐步替代2.0 |
| OAuth3 | 短生命周期令牌 | 新兴中 |
| Passkey Sync | iCloud/Google同步 | 生产可用 |
Architecture: Passwordless-First Design
架构设计:无密码优先
Recommended Auth Hierarchy (2026)
推荐的认证层级(2026)
Primary Methods (Phishing-Resistant):
├── 1. Passkeys (WebAuthn) ← PREFERRED
│ ├── Platform authenticators (Face ID, Touch ID, Windows Hello)
│ └── Roaming authenticators (YubiKey, security keys)
├── 2. Social OAuth
│ ├── Google Sign-In (synced passkeys)
│ └── Apple Sign-In (privacy-focused)
│
Fallback Methods (Lower Security):
├── 3. Magic Links (email-based)
├── 4. Email OTP (time-limited codes)
└── 5. SMS OTP (deprecated - SIM swap risk)
⚠️ SMS should be last resort only
Legacy (Avoid):
└── 6. Password + Email ← DISCOURAGE一级方法(抗钓鱼):
├── 1. Passkeys (WebAuthn) ← 优先推荐
│ ├── 平台验证器(Face ID、Touch ID、Windows Hello)
│ └── 可漫游验证器(YubiKey、安全密钥)
├── 2. 社交OAuth
│ ├── Google Sign-In(支持同步passkeys)
│ └── Apple Sign-In(隐私优先)
│
降级方法(安全性较低):
├── 3. 魔法链接(基于邮箱)
├── 4. 邮箱OTP(限时验证码)
└── 5. 短信OTP(已弃用 - 存在SIM卡劫持风险)
⚠️ 短信仅应作为最后手段
legacy方案(避免使用):
└── 6. 密码 + 邮箱 ← 不推荐Security Tier Comparison
安全等级对比
| Method | Phishing-Resistant | Device-Bound | Sync-Capable | Friction |
|---|---|---|---|---|
| Passkeys | ✅ Yes | ✅ Yes | ✅ Yes | Low |
| Hardware Key | ✅ Yes | ✅ Yes | ❌ No | Medium |
| Google OAuth | ⚠️ Partial | ❌ No | ✅ Yes | Low |
| Apple OAuth | ⚠️ Partial | ❌ No | ✅ Yes | Low |
| Magic Link | ❌ No | ❌ No | ✅ Yes | Medium |
| Email OTP | ❌ No | ❌ No | ✅ Yes | Medium |
| SMS OTP | ❌ No | ❌ No | ❌ No | Medium |
| Password | ❌ No | ❌ No | ✅ Yes | Low |
| 方法 | 抗钓鱼 | 设备绑定 | 可同步 | 操作复杂度 |
|---|---|---|---|---|
| Passkeys | ✅ 是 | ✅ 是 | ✅ 是 | 低 |
| 硬件密钥 | ✅ 是 | ✅ 是 | ❌ 否 | 中 |
| Google OAuth | ⚠️ 部分支持 | ❌ 否 | ✅ 是 | 低 |
| Apple OAuth | ⚠️ 部分支持 | ❌ 否 | ✅ 是 | 低 |
| 魔法链接 | ❌ 否 | ❌ 否 | ✅ 是 | 中 |
| 邮箱OTP | ❌ 否 | ❌ 否 | ✅ 是 | 中 |
| 短信OTP | ❌ 否 | ❌ 否 | ❌ 否 | 中 |
| 密码 | ❌ 否 | ❌ 否 | ✅ 是 | 低 |
Passkeys (WebAuthn) Implementation
Passkeys (WebAuthn) 实现
How Passkeys Work
Passkeys工作原理
Registration Flow:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ User │─────▶│ Browser │─────▶│ Server │
│ │ │ WebAuthn │ │ │
└──────────┘ └──────────┘ └──────────┘
│ │ │
│ 1. User clicks │ │
│ "Register" │ │
│ │ 2. Server sends │
│ │◀─ challenge + │
│ │ user info │
│ 3. Device shows │ │
│◀─ biometric │ │
│ │ │
│ 4. User │ │
│─▶ authenticates │ │
│ │ 5. Send public │
│ │─▶ key + signed │
│ │ challenge │
│ │ │
│ │ 6. Server stores│
│ │◀─ public key │
└──────────────────┴──────────────────┘
Key Points:
- Private key NEVER leaves device
- Server only stores public key
- Biometric data stays local
- Credential bound to domain (anti-phishing)注册流程:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 用户 │─────▶│ 浏览器 │─────▶│ 服务器 │
│ │ │ WebAuthn │ │ │
└──────────┘ └──────────┘ └──────────┘
│ │ │
│ 1. 用户点击 │ │
│ "注册"按钮 │ │
│ │ 2. 服务器发送 │
│ │◀─ 挑战码 + │
│ │ 用户信息 │
│ 3. 设备展示 │ │
│◀─ 生物识别验证 │ │
│ │ │
│ 4. 用户完成 │ │
│─▶ 身份验证 │ │
│ │ 5. 发送公钥 + │
│ │─▶ 签名后的挑战码 │
│ │ │
│ │ 6. 服务器存储 │
│ │◀─ 公钥 │
└──────────────────┴──────────────────┘
核心要点:
- 私钥永远不会离开设备
- 服务器仅存储公钥
- 生物识别数据保留在本地
- 凭证与域名绑定(抗钓鱼)Library Recommendations
推荐库
Frontend:
json
{
"@simplewebauthn/browser": "^10.0.0",
"next-passkey-webauthn": "^2.0.0"
}Backend:
json
{
"@simplewebauthn/server": "^10.0.0"
}前端:
json
{
"@simplewebauthn/browser": "^10.0.0",
"next-passkey-webauthn": "^2.0.0"
}后端:
json
{
"@simplewebauthn/server": "^10.0.0"
}Next.js Passkey Implementation
Next.js Passkeys 实现
1. Database Schema (Supabase):
sql
-- Store passkey credentials
CREATE TABLE passkey_credentials (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
credential_id text UNIQUE NOT NULL,
public_key bytea NOT NULL,
counter integer DEFAULT 0,
transports text[], -- e.g., ['internal', 'hybrid']
device_type text, -- 'platform' or 'cross-platform'
backed_up boolean DEFAULT false,
created_at timestamptz DEFAULT now(),
last_used_at timestamptz
);
CREATE INDEX idx_passkey_user_id ON passkey_credentials(user_id);
CREATE INDEX idx_passkey_credential_id ON passkey_credentials(credential_id);
-- RLS policies
ALTER TABLE passkey_credentials ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can read own credentials" ON passkey_credentials
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can insert own credentials" ON passkey_credentials
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can delete own credentials" ON passkey_credentials
FOR DELETE USING (auth.uid() = user_id);2. Registration API Route (app/api/passkeys/register/route.ts):
typescript
import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';
import { createClient } from '@/lib/supabase/server';
const RP_NAME = 'Your App Name';
const RP_ID = process.env.NODE_ENV === 'production'
? 'yourapp.com'
: 'localhost';
export async function POST(request: Request) {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const { step, credential } = await request.json();
if (step === 'options') {
// Get existing credentials to exclude
const { data: existingCreds } = await supabase
.from('passkey_credentials')
.select('credential_id')
.eq('user_id', user.id);
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userID: user.id,
userName: user.email!,
userDisplayName: user.user_metadata?.display_name || user.email!,
attestationType: 'none', // For privacy
excludeCredentials: existingCreds?.map(c => ({
id: Buffer.from(c.credential_id, 'base64url'),
type: 'public-key',
})) || [],
authenticatorSelection: {
residentKey: 'preferred', // Discoverable credentials
userVerification: 'preferred', // Biometric when available
authenticatorAttachment: 'platform', // Device-bound (not roaming keys)
},
});
// Store challenge in session (or use signed JWT)
await supabase.from('auth_challenges').upsert({
user_id: user.id,
challenge: options.challenge,
expires_at: new Date(Date.now() + 5 * 60 * 1000), // 5 min
});
return Response.json(options);
}
if (step === 'verify') {
// Get stored challenge
const { data: challengeData } = await supabase
.from('auth_challenges')
.select('challenge')
.eq('user_id', user.id)
.single();
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge: challengeData!.challenge,
expectedOrigin: process.env.NEXT_PUBLIC_APP_URL!,
expectedRPID: RP_ID,
});
if (verification.verified && verification.registrationInfo) {
const { credentialID, credentialPublicKey, counter } = verification.registrationInfo;
await supabase.from('passkey_credentials').insert({
user_id: user.id,
credential_id: Buffer.from(credentialID).toString('base64url'),
public_key: Buffer.from(credentialPublicKey),
counter,
transports: credential.response.transports,
device_type: verification.registrationInfo.credentialDeviceType,
backed_up: verification.registrationInfo.credentialBackedUp,
});
return Response.json({ success: true });
}
return Response.json({ error: 'Verification failed' }, { status: 400 });
}
}3. Authentication API Route (app/api/passkeys/authenticate/route.ts):
typescript
import { generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server';
import { createClient } from '@/lib/supabase/server';
export async function POST(request: Request) {
const supabase = createClient();
const { step, credential, email } = await request.json();
if (step === 'options') {
// For discoverable credentials, email is optional
let userCredentials = [];
if (email) {
const { data: user } = await supabase
.from('profiles')
.select('id')
.eq('email', email)
.single();
if (user) {
const { data: creds } = await supabase
.from('passkey_credentials')
.select('credential_id, transports')
.eq('user_id', user.id);
userCredentials = creds || [];
}
}
const options = await generateAuthenticationOptions({
rpID: RP_ID,
userVerification: 'preferred',
allowCredentials: userCredentials.length ? userCredentials.map(c => ({
id: Buffer.from(c.credential_id, 'base64url'),
type: 'public-key',
transports: c.transports,
})) : undefined, // Empty = discoverable credential flow
});
// Store challenge
await supabase.from('auth_challenges').upsert({
challenge_id: options.challenge,
challenge: options.challenge,
expires_at: new Date(Date.now() + 5 * 60 * 1000),
});
return Response.json(options);
}
if (step === 'verify') {
// Find credential
const credentialId = Buffer.from(credential.id, 'base64url').toString('base64url');
const { data: storedCred } = await supabase
.from('passkey_credentials')
.select('*, profiles!inner(email)')
.eq('credential_id', credentialId)
.single();
if (!storedCred) {
return Response.json({ error: 'Credential not found' }, { status: 401 });
}
// Get challenge
const { data: challengeData } = await supabase
.from('auth_challenges')
.select('challenge')
.eq('challenge_id', credential.response.clientDataJSON.challenge)
.single();
const verification = await verifyAuthenticationResponse({
response: credential,
expectedChallenge: challengeData!.challenge,
expectedOrigin: process.env.NEXT_PUBLIC_APP_URL!,
expectedRPID: RP_ID,
authenticator: {
credentialID: Buffer.from(storedCred.credential_id, 'base64url'),
credentialPublicKey: storedCred.public_key,
counter: storedCred.counter,
},
});
if (verification.verified) {
// Update counter
await supabase
.from('passkey_credentials')
.update({
counter: verification.authenticationInfo.newCounter,
last_used_at: new Date(),
})
.eq('id', storedCred.id);
// Create Supabase session
const { data: session } = await supabase.auth.admin.generateLink({
type: 'magiclink',
email: storedCred.profiles.email,
});
return Response.json({
success: true,
session: session.properties?.hashed_token
});
}
return Response.json({ error: 'Verification failed' }, { status: 401 });
}
}4. Frontend Hook (hooks/usePasskey.ts):
typescript
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
import { useState } from 'react';
export function usePasskey() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const registerPasskey = async () => {
setIsLoading(true);
setError(null);
try {
// Get options from server
const optionsRes = await fetch('/api/passkeys/register', {
method: 'POST',
body: JSON.stringify({ step: 'options' }),
});
const options = await optionsRes.json();
// Start WebAuthn registration
const credential = await startRegistration(options);
// Verify with server
const verifyRes = await fetch('/api/passkeys/register', {
method: 'POST',
body: JSON.stringify({ step: 'verify', credential }),
});
if (!verifyRes.ok) {
throw new Error('Verification failed');
}
return true;
} catch (err: any) {
// Handle user cancellation gracefully
if (err.name === 'NotAllowedError') {
setError('Passkey registration cancelled');
} else {
setError(err.message);
}
return false;
} finally {
setIsLoading(false);
}
};
const authenticateWithPasskey = async (email?: string) => {
setIsLoading(true);
setError(null);
try {
const optionsRes = await fetch('/api/passkeys/authenticate', {
method: 'POST',
body: JSON.stringify({ step: 'options', email }),
});
const options = await optionsRes.json();
const credential = await startAuthentication(options);
const verifyRes = await fetch('/api/passkeys/authenticate', {
method: 'POST',
body: JSON.stringify({ step: 'verify', credential }),
});
if (!verifyRes.ok) {
throw new Error('Authentication failed');
}
const { session } = await verifyRes.json();
// Exchange for Supabase session
// ...
return true;
} catch (err: any) {
if (err.name === 'NotAllowedError') {
setError('Passkey authentication cancelled');
} else {
setError(err.message);
}
return false;
} finally {
setIsLoading(false);
}
};
const isSupported = typeof window !== 'undefined' &&
window.PublicKeyCredential !== undefined;
return {
registerPasskey,
authenticateWithPasskey,
isSupported,
isLoading,
error,
};
}1. 数据库Schema(Supabase):
sql
-- 存储passkey凭证
CREATE TABLE passkey_credentials (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
credential_id text UNIQUE NOT NULL,
public_key bytea NOT NULL,
counter integer DEFAULT 0,
transports text[], -- 例如:['internal', 'hybrid']
device_type text, -- 'platform' 或 'cross-platform'
backed_up boolean DEFAULT false,
created_at timestamptz DEFAULT now(),
last_used_at timestamptz
);
CREATE INDEX idx_passkey_user_id ON passkey_credentials(user_id);
CREATE INDEX idx_passkey_credential_id ON passkey_credentials(credential_id);
-- RLS策略
ALTER TABLE passkey_credentials ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can read own credentials" ON passkey_credentials
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can insert own credentials" ON passkey_credentials
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can delete own credentials" ON passkey_credentials
FOR DELETE USING (auth.uid() = user_id);2. 注册API路由(app/api/passkeys/register/route.ts):
typescript
import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';
import { createClient } from '@/lib/supabase/server';
const RP_NAME = 'Your App Name';
const RP_ID = process.env.NODE_ENV === 'production'
? 'yourapp.com'
: 'localhost';
export async function POST(request: Request) {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const { step, credential } = await request.json();
if (step === 'options') {
// 获取已存在的凭证以排除
const { data: existingCreds } = await supabase
.from('passkey_credentials')
.select('credential_id')
.eq('user_id', user.id);
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userID: user.id,
userName: user.email!,
userDisplayName: user.user_metadata?.display_name || user.email!,
attestationType: 'none', // 隐私优先
excludeCredentials: existingCreds?.map(c => ({
id: Buffer.from(c.credential_id, 'base64url'),
type: 'public-key',
})) || [],
authenticatorSelection: {
residentKey: 'preferred', // 可发现凭证
userVerification: 'preferred', // 尽可能使用生物识别
authenticatorAttachment: 'platform', // 设备绑定(非漫游密钥)
},
});
// 将挑战码存储在会话中(或使用签名JWT)
await supabase.from('auth_challenges').upsert({
user_id: user.id,
challenge: options.challenge,
expires_at: new Date(Date.now() + 5 * 60 * 1000), // 5分钟
});
return Response.json(options);
}
if (step === 'verify') {
// 获取存储的挑战码
const { data: challengeData } = await supabase
.from('auth_challenges')
.select('challenge')
.eq('user_id', user.id)
.single();
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge: challengeData!.challenge,
expectedOrigin: process.env.NEXT_PUBLIC_APP_URL!,
expectedRPID: RP_ID,
});
if (verification.verified && verification.registrationInfo) {
const { credentialID, credentialPublicKey, counter } = verification.registrationInfo;
await supabase.from('passkey_credentials').insert({
user_id: user.id,
credential_id: Buffer.from(credentialID).toString('base64url'),
public_key: Buffer.from(credentialPublicKey),
counter,
transports: credential.response.transports,
device_type: verification.registrationInfo.credentialDeviceType,
backed_up: verification.registrationInfo.credentialBackedUp,
});
return Response.json({ success: true });
}
return Response.json({ error: 'Verification failed' }, { status: 400 });
}
}3. 认证API路由(app/api/passkeys/authenticate/route.ts):
typescript
import { generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server';
import { createClient } from '@/lib/supabase/server';
export async function POST(request: Request) {
const supabase = createClient();
const { step, credential, email } = await request.json();
if (step === 'options') {
// 对于可发现凭证,email是可选的
let userCredentials = [];
if (email) {
const { data: user } = await supabase
.from('profiles')
.select('id')
.eq('email', email)
.single();
if (user) {
const { data: creds } = await supabase
.from('passkey_credentials')
.select('credential_id, transports')
.eq('user_id', user.id);
userCredentials = creds || [];
}
}
const options = await generateAuthenticationOptions({
rpID: RP_ID,
userVerification: 'preferred',
allowCredentials: userCredentials.length ? userCredentials.map(c => ({
id: Buffer.from(c.credential_id, 'base64url'),
type: 'public-key',
transports: c.transports,
})) : undefined, // 空值 = 可发现凭证流程
});
// 存储挑战码
await supabase.from('auth_challenges').upsert({
challenge_id: options.challenge,
challenge: options.challenge,
expires_at: new Date(Date.now() + 5 * 60 * 1000),
});
return Response.json(options);
}
if (step === 'verify') {
// 查找凭证
const credentialId = Buffer.from(credential.id, 'base64url').toString('base64url');
const { data: storedCred } = await supabase
.from('passkey_credentials')
.select('*, profiles!inner(email)')
.eq('credential_id', credentialId)
.single();
if (!storedCred) {
return Response.json({ error: 'Credential not found' }, { status: 401 });
}
// 获取挑战码
const { data: challengeData } = await supabase
.from('auth_challenges')
.select('challenge')
.eq('challenge_id', credential.response.clientDataJSON.challenge)
.single();
const verification = await verifyAuthenticationResponse({
response: credential,
expectedChallenge: challengeData!.challenge,
expectedOrigin: process.env.NEXT_PUBLIC_APP_URL!,
expectedRPID: RP_ID,
authenticator: {
credentialID: Buffer.from(storedCred.credential_id, 'base64url'),
credentialPublicKey: storedCred.public_key,
counter: storedCred.counter,
},
});
if (verification.verified) {
// 更新计数器
await supabase
.from('passkey_credentials')
.update({
counter: verification.authenticationInfo.newCounter,
last_used_at: new Date(),
})
.eq('id', storedCred.id);
// 创建Supabase会话
const { data: session } = await supabase.auth.admin.generateLink({
type: 'magiclink',
email: storedCred.profiles.email,
});
return Response.json({
success: true,
session: session.properties?.hashed_token
});
}
return Response.json({ error: 'Verification failed' }, { status: 401 });
}
}4. 前端Hook(hooks/usePasskey.ts):
typescript
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
import { useState } from 'react';
export function usePasskey() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const registerPasskey = async () => {
setIsLoading(true);
setError(null);
try {
// 从服务器获取配置选项
const optionsRes = await fetch('/api/passkeys/register', {
method: 'POST',
body: JSON.stringify({ step: 'options' }),
});
const options = await optionsRes.json();
// 启动WebAuthn注册
const credential = await startRegistration(options);
// 与服务器验证
const verifyRes = await fetch('/api/passkeys/register', {
method: 'POST',
body: JSON.stringify({ step: 'verify', credential }),
});
if (!verifyRes.ok) {
throw new Error('Verification failed');
}
return true;
} catch (err: any) {
// 优雅处理用户取消操作
if (err.name === 'NotAllowedError') {
setError('Passkey注册已取消');
} else {
setError(err.message);
}
return false;
} finally {
setIsLoading(false);
}
};
const authenticateWithPasskey = async (email?: string) => {
setIsLoading(true);
setError(null);
try {
const optionsRes = await fetch('/api/passkeys/authenticate', {
method: 'POST',
body: JSON.stringify({ step: 'options', email }),
});
const options = await optionsRes.json();
const credential = await startAuthentication(options);
const verifyRes = await fetch('/api/passkeys/authenticate', {
method: 'POST',
body: JSON.stringify({ step: 'verify', credential }),
});
if (!verifyRes.ok) {
throw new Error('Authentication failed');
}
const { session } = await verifyRes.json();
// 交换为Supabase会话
// ...
return true;
} catch (err: any) {
if (err.name === 'NotAllowedError') {
setError('Passkey认证已取消');
} else {
setError(err.message);
}
return false;
} finally {
setIsLoading(false);
}
};
const isSupported = typeof window !== 'undefined' &&
window.PublicKeyCredential !== undefined;
return {
registerPasskey,
authenticateWithPasskey,
isSupported,
isLoading,
error,
};
}OAuth: Google Sign-In
OAuth: Google Sign-In
Setup Requirements
配置要求
-
Google Cloud Console:
- Create OAuth 2.0 Client ID (Web application)
- Add authorized JavaScript origins:
https://yourapp.com - Add authorized redirect URIs:
https://yourapp.supabase.co/auth/v1/callback
-
Supabase Dashboard:
- Authentication → Providers → Google
- Add Client ID and Client Secret
- Enable "Sign in with Google"
-
Google Cloud Console:
- 创建OAuth 2.0客户端ID(Web应用)
- 添加授权的JavaScript来源:
https://yourapp.com - 添加授权的重定向URI:
https://yourapp.supabase.co/auth/v1/callback
-
Supabase控制台:
- 认证 → 提供商 → Google
- 添加客户端ID和客户端密钥
- 启用“使用Google登录”
Implementation
实现代码
Supabase Client (Next.js):
typescript
import { createClient } from '@/lib/supabase/client';
async function signInWithGoogle() {
const supabase = createClient();
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
queryParams: {
access_type: 'offline', // For refresh tokens
prompt: 'consent', // Force consent screen
},
},
});
if (error) {
console.error('Google sign-in error:', error);
}
}Native Mobile (React Native/Expo):
typescript
import * as Google from 'expo-auth-session/providers/google';
import { createClient } from '@supabase/supabase-js';
export function useGoogleAuth() {
const [request, response, promptAsync] = Google.useIdTokenAuthRequest({
clientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID,
iosClientId: process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID,
androidClientId: process.env.EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID,
});
useEffect(() => {
if (response?.type === 'success') {
const { id_token } = response.params;
supabase.auth.signInWithIdToken({
provider: 'google',
token: id_token,
});
}
}, [response]);
return { signIn: () => promptAsync(), isLoading: !request };
}Supabase客户端(Next.js):
typescript
import { createClient } from '@/lib/supabase/client';
async function signInWithGoogle() {
const supabase = createClient();
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
queryParams: {
access_type: 'offline', // 用于刷新令牌
prompt: 'consent', // 强制显示授权界面
},
},
});
if (error) {
console.error('Google登录错误:', error);
}
}原生移动端(React Native/Expo):
typescript
import * as Google from 'expo-auth-session/providers/google';
import { createClient } from '@supabase/supabase-js';
export function useGoogleAuth() {
const [request, response, promptAsync] = Google.useIdTokenAuthRequest({
clientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID,
iosClientId: process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID,
androidClientId: process.env.EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID,
});
useEffect(() => {
if (response?.type === 'success') {
const { id_token } = response.params;
supabase.auth.signInWithIdToken({
provider: 'google',
token: id_token,
});
}
}, [response]);
return { signIn: () => promptAsync(), isLoading: !request };
}OAuth: Apple Sign-In
OAuth: Apple Sign-In
App Store Requirements (2024+)
App Store要求(2024+)
⚠️ Critical Compliance Rule:
Apps that use third-party login (Google, Facebook, etc.) must also offer an equivalent privacy-focused option. Sign in with Apple satisfies this requirement.
Required if you offer: Google, Facebook, Twitter, Amazon, WeChat login
Exception: Enterprise/education apps with existing SSO
⚠️ 关键合规规则:
如果应用使用第三方登录(Google、Facebook等),必须同时提供同等隐私保护的登录选项。Apple Sign-In可满足此要求。
适用场景: 提供Google、Facebook、Twitter、Amazon、微信登录的应用
例外情况: 企业/教育类应用已存在SSO的情况
Setup Requirements
配置要求
-
Apple Developer Portal:
- Enable "Sign in with Apple" capability
- Create Service ID for web
- Create Key (.p8 file) for token generation
- ⚠️ Key expires every 6 months - set calendar reminder!
-
Supabase Dashboard:
- Authentication → Providers → Apple
- Add Service ID, Team ID, Key ID
- Upload .p8 key file
-
Apple开发者中心:
- 启用“Sign in with Apple”功能
- 为Web应用创建服务ID
- 创建用于令牌生成的密钥(.p8文件)
- ⚠️ 密钥每6个月过期 - 请设置日历提醒!
-
Supabase控制台:
- 认证 → 提供商 → Apple
- 添加服务ID、团队ID、密钥ID
- 上传.p8密钥文件
Implementation
实现代码
Web (Supabase):
typescript
async function signInWithApple() {
const supabase = createClient();
const { error } = await supabase.auth.signInWithOAuth({
provider: 'apple',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
});
if (error) {
console.error('Apple sign-in error:', error);
}
}Native iOS (Swift):
swift
import AuthenticationServices
func handleAppleSignIn() async throws {
let appleIDProvider = ASAuthorizationAppleIDProvider()
let request = appleIDProvider.createRequest()
request.requestedScopes = [.fullName, .email]
let result = try await performSignIn(request)
// Extract ID token
guard let identityToken = result.credential.identityToken,
let tokenString = String(data: identityToken, encoding: .utf8) else {
throw AuthError.missingToken
}
// Sign in to Supabase
try await supabase.auth.signInWithIdToken(
credentials: .init(
provider: .apple,
idToken: tokenString
)
)
}Web端(Supabase):
typescript
async function signInWithApple() {
const supabase = createClient();
const { error } = await supabase.auth.signInWithOAuth({
provider: 'apple',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
});
if (error) {
console.error('Apple登录错误:', error);
}
}原生iOS(Swift):
swift
import AuthenticationServices
func handleAppleSignIn() async throws {
let appleIDProvider = ASAuthorizationAppleIDProvider()
let request = appleIDProvider.createRequest()
request.requestedScopes = [.fullName, .email]
let result = try await performSignIn(request)
// 提取ID令牌
guard let identityToken = result.credential.identityToken,
let tokenString = String(data: identityToken, encoding: .utf8) else {
throw AuthError.missingToken
}
// 登录到Supabase
try await supabase.auth.signInWithIdToken(
credentials: .init(
provider: .apple,
idToken: tokenString
)
)
}Magic Links (Email Passwordless)
魔法链接(邮箱无密码登录)
Best Practices
最佳实践
typescript
// ✅ Good: Short TTL, single-use
const { error } = await supabase.auth.signInWithOtp({
email: user.email,
options: {
emailRedirectTo: `${origin}/auth/callback`,
shouldCreateUser: true, // Auto-create on first login
},
});
// Configure in Supabase Dashboard:
// - Magic Link expiry: 5-10 minutes (shorter is safer)
// - Rate limit: 3 per hour per emailtypescript
// ✅ 推荐:短有效期、单次使用
const { error } = await supabase.auth.signInWithOtp({
email: user.email,
options: {
emailRedirectTo: `${origin}/auth/callback`,
shouldCreateUser: true, // 首次登录时自动创建用户
},
});
// 在Supabase控制台配置:
// - 魔法链接过期时间:5-10分钟(越短越安全)
// - 频率限制:每小时每个邮箱最多3次Email Template Customization
邮箱模板自定义
html
<!-- Supabase Dashboard → Auth → Email Templates → Magic Link -->
<h2>Sign in to {{ .SiteURL }}</h2>
<p>Click the link below to sign in. This link expires in 10 minutes.</p>
<p><a href="{{ .ConfirmationURL }}">Sign in to Your Account</a></p>
<p>If you didn't request this, you can safely ignore this email.</p>html
<!-- Supabase控制台 → 认证 → 邮箱模板 → 魔法链接 -->
<h2>登录到 {{ .SiteURL }}</h2>
<p>点击下方链接登录,该链接10分钟后过期。</p>
<p><a href="{{ .ConfirmationURL }}">登录您的账户</a></p>
<p>如果您未发起此请求,可忽略此邮件。</p>Recovery Flows
恢复流程
Email Recovery (Password Reset)
邮箱恢复(密码重置)
typescript
// Request reset
await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${origin}/auth/update-password`,
});
// Update password (on /auth/update-password page)
await supabase.auth.updateUser({ password: newPassword });typescript
// 请求重置
await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${origin}/auth/update-password`,
});
// 更新密码(在/auth/update-password页面)
await supabase.auth.updateUser({ password: newPassword });Account Recovery Hierarchy
账户恢复层级
Recovery Options (in order of security):
1. Backup Passkey (stored on different device)
2. Trusted Recovery Contact (delegated access)
3. Email verification + Security questions
4. Email-only recovery (last resort)
5. SMS recovery ⚠️ (vulnerable to SIM swap)恢复选项(按安全性排序):
1. 备用Passkey(存储在其他设备)
2. 可信恢复联系人(委托访问权限)
3. 邮箱验证 + 安全问题
4. 仅邮箱恢复(最后手段)
5. 短信恢复 ⚠️(易受SIM卡劫持攻击)Implementing Backup Passkeys
实现备用Passkeys
typescript
// Prompt user to register backup device after primary
function PromptBackupPasskey() {
const [hasBackup, setHasBackup] = useState(false);
const { data: credentials } = usePasskeyCredentials();
useEffect(() => {
// Check if user has only one passkey
if (credentials?.length === 1) {
setHasBackup(false);
}
}, [credentials]);
if (hasBackup) return null;
return (
<div className="bg-amber-50 border border-amber-200 p-4 rounded-lg">
<h3>Add a Backup Passkey</h3>
<p>Register a passkey on another device to ensure account recovery.</p>
<Button onClick={registerPasskey}>Add Backup Device</Button>
</div>
);
}typescript
// 在用户注册主Passkey后,提示注册备用设备
function PromptBackupPasskey() {
const [hasBackup, setHasBackup] = useState(false);
const { data: credentials } = usePasskeyCredentials();
useEffect(() => {
// 检查用户是否只有一个passkey
if (credentials?.length === 1) {
setHasBackup(false);
}
}, [credentials]);
if (hasBackup) return null;
return (
<div className="bg-amber-50 border border-amber-200 p-4 rounded-lg">
<h3>添加备用Passkey</h3>
<p>在其他设备上注册Passkey,确保账户可恢复。</p>
<Button onClick={registerPasskey}>添加备用设备</Button>
</div>
);
}Cross-Device Sync
跨设备同步
How Passkey Sync Works
Passkey同步原理
Device A (iPhone) iCloud Keychain Device B (Mac)
┌─────────────────┐ ┌─────────────┐ ┌─────────────────┐
│ Create Passkey │──────────▶│ E2E Encrypt │──────────▶│ Passkey Ready │
│ for example.com │ │ & Sync │ │ to use │
└─────────────────┘ └─────────────┘ └─────────────────┘
Google Password Manager:
- Android devices synced
- Chrome browser synced
- Windows via Chrome
Apple iCloud Keychain:
- All Apple devices synced
- Safari on all platforms
- Shared with Family Sharing (optional)设备A(iPhone) iCloud钥匙串 设备B(Mac)
┌─────────────────┐ ┌─────────────┐ ┌─────────────────┐
│ 创建Passkey │──────────▶│ 端到端加密 │──────────▶│ 可使用Passkey │
│ 用于example.com │ │ 并同步 │ │ │
└─────────────────┘ └─────────────┘ └─────────────────┘
Google密码管理器:
- 安卓设备间同步
- Chrome浏览器同步
- Windows通过Chrome同步
Apple iCloud钥匙串:
- 所有Apple设备间同步
- 全平台Safari支持
- 可通过家庭共享同步(可选)Cross-Platform Authentication (QR Code)
跨平台认证(二维码)
When user wants to sign in on a device without their passkey:
typescript
// Device A shows QR code
// User scans with phone (Device B) that has passkey
// Phone authenticates via Bluetooth proximity
// This is handled automatically by the browser's WebAuthn implementation
// No additional code needed - just allow hybrid transports:
const options = await generateAuthenticationOptions({
rpID: RP_ID,
authenticatorSelection: {
// Allow cross-device (QR code) authentication
authenticatorAttachment: undefined, // Don't restrict
},
});当用户想在没有Passkey的设备上登录时:
typescript
// 设备A展示二维码
// 用户使用已存储Passkey的手机(设备B)扫描
// 手机通过蓝牙近场验证
// 浏览器的WebAuthn实现会自动处理此流程
// 无需额外代码 - 只需允许混合传输方式:
const options = await generateAuthenticationOptions({
rpID: RP_ID,
authenticatorSelection: {
// 允许跨设备(二维码)认证
authenticatorAttachment: undefined, // 不做限制
},
});Supabase Auth Configuration Checklist
Supabase Auth配置检查清单
Dashboard Settings
控制台设置
-
Authentication → Settings:
- Site URL:
https://yourapp.com - Redirect URLs: Add all valid callbacks
- JWT Expiry: 3600 (1 hour)
- Enable email confirmations: Yes
- Site URL:
-
Authentication → Providers → Email:
- Enable Email: Yes
- Confirm email: Yes (recommended)
- Secure email change: Yes
- Double confirm email: No (reduces friction)
-
Authentication → Email Templates:
- Customize all templates
- Test email delivery
- Set appropriate expiry times
-
Authentication → Rate Limiting:
- Email: 3 per hour
- SMS: 3 per hour
- Magic links: 3 per 5 minutes
-
认证 → 设置:
- 站点URL:
https://yourapp.com - 重定向URL:添加所有有效的回调地址
- JWT过期时间:3600(1小时)
- 启用邮箱确认:是
- 站点URL:
-
认证 → 提供商 → 邮箱:
- 启用邮箱:是
- 确认邮箱:是(推荐)
- 安全邮箱变更:是
- 双重确认邮箱:否(减少操作复杂度)
-
认证 → 邮箱模板:
- 自定义所有模板
- 测试邮件发送
- 设置合理的过期时间
-
认证 → 频率限制:
- 邮箱:每小时3次
- 短信:每小时3次
- 魔法链接:每5分钟3次
Environment Variables
环境变量
env
undefinedenv
undefinedRequired
必填
NEXT_PUBLIC_SUPABASE_URL=https://yourproject.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-key
NEXT_PUBLIC_SUPABASE_URL=https://yourproject.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-key
Google OAuth
Google OAuth
GOOGLE_CLIENT_ID=your-client-id
GOOGLE_CLIENT_SECRET=your-client-secret
GOOGLE_CLIENT_ID=your-client-id
GOOGLE_CLIENT_SECRET=your-client-secret
Apple OAuth
Apple OAuth
APPLE_SERVICE_ID=your-service-id
APPLE_TEAM_ID=your-team-id
APPLE_KEY_ID=your-key-id
APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n..."
APPLE_SERVICE_ID=your-service-id
APPLE_TEAM_ID=your-team-id
APPLE_KEY_ID=your-key-id
APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n..."
Passkeys
Passkeys
PASSKEY_RP_ID=yourapp.com
PASSKEY_RP_NAME="Your App Name"
---PASSKEY_RP_ID=yourapp.com
PASSKEY_RP_NAME="Your App Name"
---Common Issues & Solutions
常见问题与解决方案
Issue: Sign-up says "Check email" but no email arrives
问题:注册提示“检查邮箱”但未收到邮件
Cause: Email confirmation not configured in Supabase Dashboard
Solution:
- Go to Supabase Dashboard → Authentication → Providers → Email
- Verify "Confirm email" is enabled
- Check email templates are configured
- Verify SMTP settings (or use Supabase's built-in email)
- Check spam folder
原因: Supabase控制台未配置邮箱确认
解决方案:
- 进入Supabase控制台 → 认证 → 提供商 → 邮箱
- 确认“确认邮箱”已启用
- 检查邮箱模板已配置
- 验证SMTP设置(或使用Supabase内置邮件服务)
- 检查垃圾邮件文件夹
Issue: Apple Sign-In suddenly stops working
问题:Apple Sign-In突然无法使用
Cause: Apple .p8 key expired (6-month limit)
Solution:
- Generate new key in Apple Developer Portal
- Update key in Supabase Dashboard
- Set calendar reminder for next expiry
原因: Apple .p8密钥过期(有效期6个月)
解决方案:
- 在Apple开发者中心生成新密钥
- 在Supabase控制台更新密钥
- 设置日历提醒下一次过期时间
Issue: Google OAuth redirect error
问题:Google OAuth重定向错误
Cause: Redirect URI mismatch
Solution:
- Verify redirect URI in Google Cloud Console matches exactly:
https://yourproject.supabase.co/auth/v1/callback
- Check for trailing slashes
- Ensure HTTP vs HTTPS matches
原因: 重定向URI不匹配
解决方案:
- 验证Google Cloud Console中的重定向URI与以下地址完全一致:
https://yourproject.supabase.co/auth/v1/callback
- 检查是否存在尾随斜杠
- 确保HTTP/HTTPS协议一致
Issue: Passkey not syncing between devices
问题:Passkey无法在设备间同步
Cause: Credential created with wrong attachment type
Solution:
typescript
// Use 'platform' for synced credentials
authenticatorAttachment: 'platform', // NOT 'cross-platform'
// 'cross-platform' = hardware security keys (no sync)
// 'platform' = device biometrics (sync via iCloud/Google)原因: 凭证创建时使用了错误的附件类型
解决方案:
typescript
// 使用'platform'以支持同步凭证
authenticatorAttachment: 'platform', // 不要使用'cross-platform'
// 'cross-platform' = 硬件安全密钥(不支持同步)
// 'platform' = 设备生物识别(通过iCloud/Google同步)Security Best Practices
安全最佳实践
Token Management
令牌管理
typescript
// ✅ Good: Short-lived access tokens + refresh
const session = await supabase.auth.getSession();
// Access token: 1 hour
// Refresh token: 7 days (rotate on use)
// ✅ Good: Secure token storage
// Browser: HttpOnly cookies (Supabase handles this)
// Mobile: Secure Keychain/Keystore
// ❌ Bad: Long-lived tokens in localStorage
localStorage.setItem('token', longLivedToken); // DON'Ttypescript
// ✅ 推荐:短有效期访问令牌 + 刷新令牌
const session = await supabase.auth.getSession();
// 访问令牌:1小时
// 刷新令牌:7天(使用时自动轮换)
// ✅ 推荐:安全存储令牌
// 浏览器:HttpOnly Cookie(Supabase自动处理)
// 移动端:安全钥匙串/密钥库
// ❌ 不推荐:在localStorage中存储长有效期令牌
localStorage.setItem('token', longLivedToken); // 不要这样做Rate Limiting
频率限制
typescript
// Implement rate limiting on auth endpoints
const rateLimit = {
signIn: { max: 5, windowMs: 15 * 60 * 1000 }, // 5 per 15 min
signUp: { max: 3, windowMs: 60 * 60 * 1000 }, // 3 per hour
passwordReset: { max: 3, windowMs: 60 * 60 * 1000 },
passkey: { max: 10, windowMs: 15 * 60 * 1000 },
};typescript
// 在认证端点实现频率限制
const rateLimit = {
signIn: { max: 5, windowMs: 15 * 60 * 1000 }, // 15分钟内最多5次
signUp: { max: 3, windowMs: 60 * 60 * 1000 }, // 1小时内最多3次
passwordReset: { max: 3, windowMs: 60 * 60 * 1000 },
passkey: { max: 10, windowMs: 15 * 60 * 1000 },
};Secure Defaults
安全默认配置
typescript
// Always verify email on signup
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${origin}/auth/callback`,
// Supabase will only create confirmed user after email click
},
});
// Require email verification for sensitive actions
async function sensitiveAction(userId: string) {
const { data: user } = await supabase.auth.getUser();
if (!user?.email_confirmed_at) {
throw new Error('Please verify your email first');
}
// Proceed with action...
}typescript
// 注册时始终验证邮箱
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${origin}/auth/callback`,
// 用户点击邮箱链接后,Supabase才会创建已确认的用户
},
});
// 敏感操作需要验证邮箱
async function sensitiveAction(userId: string) {
const { data: user } = await supabase.auth.getUser();
if (!user?.email_confirmed_at) {
throw new Error('请先验证您的邮箱');
}
// 执行操作...
}References
参考资料
Official Documentation
官方文档
Libraries
工具库
- SimpleWebAuthn - Recommended WebAuthn library
- Corbado - Passkey-as-a-service option
- Hanko - Open-source passkey server
- SimpleWebAuthn - 推荐的WebAuthn库
- Corbado - Passkey即服务方案
- Hanko - 开源Passkey服务器