modern-auth-2026

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Modern 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
    security-auditor
    skill
  • API key management → use
    api-architect
    skill
  • Supabase RLS policies → use
    supabase-admin
    skill

适用于以下场景:
  • 实现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

核心标准

StandardPurposeStatus
WebAuthn L2Browser passkey APIFully supported
FIDO2/CTAP2Cross-platform passkeysMature
OAuth 2.1Simplified OAuthReplacing 2.0
OAuth3Short-lived tokensEmerging
Passkey SynciCloud/Google syncProduction

标准用途状态
WebAuthn L2浏览器passkey API完全支持
FIDO2/CTAP2跨平台passkeys成熟可用
OAuth 2.1简化版OAuth逐步替代2.0
OAuth3短生命周期令牌新兴中
Passkey SynciCloud/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

安全等级对比

MethodPhishing-ResistantDevice-BoundSync-CapableFriction
Passkeys✅ Yes✅ Yes✅ YesLow
Hardware Key✅ Yes✅ Yes❌ NoMedium
Google OAuth⚠️ Partial❌ No✅ YesLow
Apple OAuth⚠️ Partial❌ No✅ YesLow
Magic Link❌ No❌ No✅ YesMedium
Email OTP❌ No❌ No✅ YesMedium
SMS OTP❌ No❌ No❌ NoMedium
Password❌ No❌ No✅ YesLow

方法抗钓鱼设备绑定可同步操作复杂度
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

配置要求

  1. 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
  2. Supabase Dashboard:
    • Authentication → Providers → Google
    • Add Client ID and Client Secret
    • Enable "Sign in with Google"
  1. Google Cloud Console:
    • 创建OAuth 2.0客户端ID(Web应用)
    • 添加授权的JavaScript来源:
      https://yourapp.com
    • 添加授权的重定向URI:
      https://yourapp.supabase.co/auth/v1/callback
  2. 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

配置要求

  1. 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!
  2. Supabase Dashboard:
    • Authentication → Providers → Apple
    • Add Service ID, Team ID, Key ID
    • Upload .p8 key file
  1. Apple开发者中心:
    • 启用“Sign in with Apple”功能
    • 为Web应用创建服务ID
    • 创建用于令牌生成的密钥(.p8文件)
    • ⚠️ 密钥每6个月过期 - 请设置日历提醒!
  2. 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 email
typescript
// ✅ 推荐:短有效期、单次使用
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

控制台设置

  1. Authentication → Settings:
    • Site URL:
      https://yourapp.com
    • Redirect URLs: Add all valid callbacks
    • JWT Expiry: 3600 (1 hour)
    • Enable email confirmations: Yes
  2. Authentication → Providers → Email:
    • Enable Email: Yes
    • Confirm email: Yes (recommended)
    • Secure email change: Yes
    • Double confirm email: No (reduces friction)
  3. Authentication → Email Templates:
    • Customize all templates
    • Test email delivery
    • Set appropriate expiry times
  4. Authentication → Rate Limiting:
    • Email: 3 per hour
    • SMS: 3 per hour
    • Magic links: 3 per 5 minutes
  1. 认证 → 设置:
    • 站点URL:
      https://yourapp.com
    • 重定向URL:添加所有有效的回调地址
    • JWT过期时间:3600(1小时)
    • 启用邮箱确认:是
  2. 认证 → 提供商 → 邮箱:
    • 启用邮箱:是
    • 确认邮箱:是(推荐)
    • 安全邮箱变更:是
    • 双重确认邮箱:否(减少操作复杂度)
  3. 认证 → 邮箱模板:
    • 自定义所有模板
    • 测试邮件发送
    • 设置合理的过期时间
  4. 认证 → 频率限制:
    • 邮箱:每小时3次
    • 短信:每小时3次
    • 魔法链接:每5分钟3次

Environment Variables

环境变量

env
undefined
env
undefined

Required

必填

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:
  1. Go to Supabase Dashboard → Authentication → Providers → Email
  2. Verify "Confirm email" is enabled
  3. Check email templates are configured
  4. Verify SMTP settings (or use Supabase's built-in email)
  5. Check spam folder
原因: Supabase控制台未配置邮箱确认
解决方案:
  1. 进入Supabase控制台 → 认证 → 提供商 → 邮箱
  2. 确认“确认邮箱”已启用
  3. 检查邮箱模板已配置
  4. 验证SMTP设置(或使用Supabase内置邮件服务)
  5. 检查垃圾邮件文件夹

Issue: Apple Sign-In suddenly stops working

问题:Apple Sign-In突然无法使用

Cause: Apple .p8 key expired (6-month limit)
Solution:
  1. Generate new key in Apple Developer Portal
  2. Update key in Supabase Dashboard
  3. Set calendar reminder for next expiry
原因: Apple .p8密钥过期(有效期6个月)
解决方案:
  1. 在Apple开发者中心生成新密钥
  2. 在Supabase控制台更新密钥
  3. 设置日历提醒下一次过期时间

Issue: Google OAuth redirect error

问题:Google OAuth重定向错误

Cause: Redirect URI mismatch
Solution:
  1. Verify redirect URI in Google Cloud Console matches exactly:
    • https://yourproject.supabase.co/auth/v1/callback
  2. Check for trailing slashes
  3. Ensure HTTP vs HTTPS matches
原因: 重定向URI不匹配
解决方案:
  1. 验证Google Cloud Console中的重定向URI与以下地址完全一致:
    • https://yourproject.supabase.co/auth/v1/callback
  2. 检查是否存在尾随斜杠
  3. 确保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'T
typescript
// ✅ 推荐:短有效期访问令牌 + 刷新令牌
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

工具库

Research (2026)

2026年研究报告