WebAuthn 與 Passkeys 實戰:構建生產級無密碼認證系統

安全指南

密碼終於走到盡頭了

一個使用者在第 47 個服務上輸入了 "Summer2024!"。資料庫裡存著 $2b$10$...——bcrypt 雜湊加鹽,安全無虞。然後一封釣魚郵件來了,使用者在一個仿冒域名上輸入了那個密碼,攻擊者就以該使用者的身分登入了。再強的雜湊演算法也救不了憑證復用和釣魚攻擊。

WebAuthn 在協定層面解決了這個問題。不再使用共享密鑰,瀏覽器生成公私鑰對。伺服器只儲存公鑰。私鑰永遠不會離開使用者的裝置。認證變成了密碼學證明:使用者透過簽署伺服器發來的隨機挑戰,證明自己掌控著私鑰。

2026 年,所有主流平台都支援 WebAuthn。Passkeys——Apple 和 Google 面向消費者的品牌包裝——讓使用者體驗變得無縫。這篇指南涵蓋了從密碼學原理到生產部署的完整實作。


WebAuthn 到底怎麼運作的

兩大儀式

WebAuthn 只有兩個操作:

儀式 用途 發生了什麼
註冊(navigator.credentials.create) 建立新憑證 瀏覽器生成密鑰對 → 將公鑰發送給伺服器
認證(navigator.credentials.get) 證明身分 伺服器發送挑戰 → 瀏覽器用私鑰簽名 → 伺服器驗證

密碼學流程

註冊流程:
  用戶端                            伺服器
    |                               |
    |--- GET /register/options ---->|
    |                               |--- 生成挑戰(隨機 32 位元組)
    |<-- challenge + 使用者資訊 -----|
    |                               |
  瀏覽器生成:                         |
    - ECDSA P-256 密鑰對              |
    - 憑證 ID                        |
    - 證明簽名                        |
    |                               |
    |--- publicKey + signature ---->|
    |                               |--- 驗證證明
    |                               |--- 儲存 publicKey + credentialID
    |<-- 200 OK --------------------|

認證流程:
  用戶端                            伺服器
    |                               |
    |--- GET /login/options ------->|
    |                               |--- 生成 challenge + 允許的 credential ID 列表
    |<-- challenge + allowList -----|
    |                               |
  瀏覽器:                            |
    - 使用者驗證(生物辨識/PIN)         |
    - 用私鑰簽署 challenge             |
    |                               |
    |--- signature + credentialID ->|
    |                               |--- 取出儲存的公鑰
    |                               |--- 驗證簽名與 challenge
    |                               |--- 更新 signCount
    |<-- session token -------------|

關鍵洞察:私鑰從未離開認證器。註冊時不離開,認證時也不離開。伺服器永遠看不到它。即使你的資料庫被拖庫,攻擊者也冒充不了任何使用者,因為他們沒有私鑰。


伺服器端實作(Node.js)

依賴

npm install @simplewebauthn/server @simplewebauthn/browser

註冊:生成選項

// server/webauthn.ts
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from "@simplewebauthn/server";
import type {
  AuthenticatorTransportFuture,
  CredentialDeviceType,
} from "@simplewebauthn/server";

// 配置項:務必匹配你的部署環境
const RP_NAME = "MyApp";
const RP_ID = "myapp.com"; // 你的域名——無協定,無連接埠
const RP_ORIGIN = "https://myapp.com";
// POST /api/webauthn/register/options
export async function generateRegisterOptions(userId: string, userName: string) {
  // 取得已有憑證,防止重複註冊
  const existingCredentials = await db.credentials.findMany({
    where: { userId },
    select: { credentialId: true },
  });

  const options = await generateRegistrationOptions({
    rpName: RP_NAME,
    rpID: RP_ID,
    userName,
    userDisplayName: userName,
    // 證明類型:決定你對認證器的信任度要求
    attestationType: "none", // "none" | "indirect" | "direct"
    excludeCredentials: existingCredentials.map((cred) => ({
      id: cred.credentialId,
      type: "public-key" as const,
    })),
    authenticatorSelection: {
      // residentKey: "required" 啟用 Passkeys(可發現憑證)
      residentKey: "required",
      // 使用者驗證:"required" 表示要求生物辨識或 PIN
      userVerification: "preferred",
    },
    // 支援的演算法:ES256 和 RS256 都是通用支援
    supportedAlgorithmIDs: [-7, -257], // ES256, RS256
  });

  // 暫存 challenge——驗證時需要
  await db.challenges.create({
    data: {
      userId,
      challenge: options.challenge,
      expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5 分鐘過期
    },
  });

  return options;
}

residentKey: "required" 的決策點:它啟用了 Passkeys——憑證變得「可發現」,意味著瀏覽器可以在不需要伺服器提供 credential ID 的情況下列出可用憑證。不設定它,認證時就需要傳 allowCredentials,這要求使用者先輸入使用者名稱(傳統的兩步登入)。

註冊:驗證回應

// POST /api/webauthn/register/verify
export async function verifyRegistration(
  userId: string,
  response: AuthenticatorAttestationResponse
) {
  // 獲取註冊時儲存的 challenge
  const storedChallenge = await db.challenges.findFirst({
    where: { userId, expiresAt: { gt: new Date() } },
    orderBy: { createdAt: "desc" },
  });

  if (!storedChallenge) {
    throw new Error("Challenge 已過期或未找到。請重新開始註冊。");
  }

  const verification = await verifyRegistrationResponse({
    response,
    expectedChallenge: storedChallenge.challenge,
    expectedOrigin: RP_ORIGIN,
    expectedRPID: RP_ID,
    requireUserVerification: false,
  });

  const { verified, registrationInfo } = verification;

  if (!verified || !registrationInfo) {
    throw new Error("註冊驗證失敗");
  }

  const {
    credentialID,
    credentialPublicKey,
    counter,
    credentialDeviceType,
    credentialBackedUp,
  } = registrationInfo;

  // 儲存憑證
  await db.credentials.create({
    data: {
      userId,
      credentialId: Buffer.from(credentialID).toString("base64url"),
      publicKey: Buffer.from(credentialPublicKey).toString("base64url"),
      counter,
      deviceType: credentialDeviceType as CredentialDeviceType,
      backedUp: credentialBackedUp,
      transports: response.response.getTransports()?.join(",") || "",
    },
  });

  // 清理已使用的 challenge
  await db.challenges.delete({ where: { id: storedChallenge.id } });

  return { verified: true };
}

認證:生成選項

// POST /api/webauthn/login/options
export async function generateAuthOptions(userId?: string) {
  let allowCredentials;

  if (userId) {
    // 條件 UI / 自動填充:我們知道使用者是誰
    const credentials = await db.credentials.findMany({
      where: { userId },
      select: { credentialId: true, transports: true },
    });

    allowCredentials = credentials.map((cred) => ({
      id: cred.credentialId,
      type: "public-key" as const,
      transports: cred.transports?.split(",") as AuthenticatorTransport[],
    }));
  }
  // 如果沒有 userId,走可發現憑證流程(Passkeys)
  // 瀏覽器會展示當前 RP 可用的憑證列表

  const options = await generateAuthenticationOptions({
    rpID: RP_ID,
    allowCredentials,
    userVerification: "preferred",
  });

  // 儲存 challenge
  await db.challenges.create({
    data: {
      challenge: options.challenge,
      expiresAt: new Date(Date.now() + 5 * 60 * 1000),
    },
  });

  return options;
}

認證:驗證回應

// POST /api/webauthn/login/verify
export async function verifyAuthentication(
  response: AuthenticatorAssertionResponse
) {
  const credentialId = response.id;

  // 獲取儲存的憑證
  const credential = await db.credentials.findFirst({
    where: { credentialId },
  });

  if (!credential) {
    throw new Error("憑證未找到");
  }

  // 獲取最新 challenge
  const storedChallenge = await db.challenges.findFirst({
    where: { expiresAt: { gt: new Date() } },
    orderBy: { createdAt: "desc" },
  });

  if (!storedChallenge) {
    throw new Error("Challenge 已過期,請重試。");
  }

  const verification = await verifyAuthenticationResponse({
    response,
    expectedChallenge: storedChallenge.challenge,
    expectedOrigin: RP_ORIGIN,
    expectedRPID: RP_ID,
    authenticator: {
      credentialID: credential.credentialId,
      credentialPublicKey: new Uint8Array(
        Buffer.from(credential.publicKey, "base64url")
      ),
      counter: credential.counter,
      transports: credential.transports?.split(",") as AuthenticatorTransportFuture[],
    },
    requireUserVerification: false,
  });

  const { verified, authenticationInfo } = verification;

  if (!verified) {
    throw new Error("身分驗證失敗");
  }

  // 更新計數器(防止重放攻擊)
  await db.credentials.update({
    where: { id: credential.id },
    data: {
      counter: authenticationInfo.newCounter,
      lastUsedAt: new Date(),
    },
  });

  // 清理 challenge
  await db.challenges.delete({ where: { id: storedChallenge.id } });

  return { verified: true, userId: credential.userId };
}

用戶端實作

// lib/webauthn.ts
import {
  startRegistration,
  startAuthentication,
} from "@simplewebauthn/browser";

export async function registerPasskey(userId: string, userName: string) {
  // 第一步:從伺服器獲取註冊選項
  const optionsResp = await fetch("/api/webauthn/register/options", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ userId, userName }),
  });
  const options = await optionsResp.json();

  // 第二步:在瀏覽器中建立憑證
  let attResp: Awaited<ReturnType<typeof startRegistration>>;
  try {
    attResp = await startRegistration(options);
  } catch (error: any) {
    if (error.name === "InvalidStateError") {
      // 使用者取消了操作——正常行為
      return { cancelled: true };
    }
    throw error;
  }

  // 第三步:將證明發送給伺服器驗證
  const verifyResp = await fetch("/api/webauthn/register/verify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ userId, response: attResp }),
  });

  const { verified } = await verifyResp.json();
  return { verified };
}

export async function authenticateWithPasskey() {
  // 第一步:獲取認證選項(無 userId——使用可發現憑證)
  const optionsResp = await fetch("/api/webauthn/login/options", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({}),
  });
  const options = await optionsResp.json();

  // 第二步:從認證器取得斷言
  let authResp: Awaited<ReturnType<typeof startAuthentication>>;
  try {
    authResp = await startAuthentication(options, true); // true = 使用瀏覽器自動填充
  } catch (error: any) {
    if (error.name === "NotAllowedError") {
      return { cancelled: true };
    }
    throw error;
  }

  // 第三步:與伺服器驗證
  const verifyResp = await fetch("/api/webauthn/login/verify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ response: authResp }),
  });

  const result = await verifyResp.json();
  return result;
}

React 元件

// components/PasskeyButton.tsx
"use client";

import { startAuthentication } from "@simplewebauthn/browser";
import { useState } from "react";

export function PasskeyLoginButton() {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handlePasskeyLogin = async () => {
    setLoading(true);
    setError(null);

    try {
      const autoLoginResp = await startAuthentication({ challenge: "..." }, true);

      if (!autoLoginResp) return; // 使用者關閉了選擇器

      const verifyResp = await fetch("/api/webauthn/login/verify", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ response: autoLoginResp }),
      });

      if (verifyResp.ok) {
        window.location.href = "/dashboard";
      }
    } catch (err: any) {
      setError(err.message || "認證失敗");
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <button
        onClick={handlePasskeyLogin}
        disabled={loading}
        className="passkey-btn"
      >
        <svg width="20" height="20" viewBox="0 0 24 24">
          <path d="M12 2C9.243 2 7 4.243 7 7v3H6c-1.103 0-2 .897-2 2v8c0 1.103.897 2 2 2h12c1.103 0 2-.897 2-2v-8c0-1.103-.897-2-2-2h-1V7c0-2.757-2.243-5-5-5z"/>
        </svg>
        {loading ? "驗證中..." : "使用 Passkey 登入"}
      </button>
      {error && <p className="error">{error}</p>}
    </div>
  );
}

證明(Attestation):驗證認證器真偽

證明用於確認密鑰對是在真實認證器中生成的。有三個級別:

證明類型 它能證明什麼 隱私影響 使用場景
none 不提供認證器相關資訊 最佳隱私 大多數消費者應用
indirect 認證器是真品(匿名化) 良好隱私 有策略要求的企業應用
direct 精確的認證器型號和製造商 隱私較差 高安全性的政府/軍事場景

90% 的應用場景下,none 就夠了。你驗證的是使用者掌控私鑰,而不是認證他們的硬體。

// 需要嚴格證明的場景
const options = await generateRegistrationOptions({
  // ...
  attestationType: "direct",
  attestationFormats: ["packed", "tpm", "android-safetynet"],
});

平台認證器 vs 跨平台認證器

類型 示例 優點 缺點
平台 Touch ID、Face ID、Windows Hello、安卓生物辨識 極致體驗,無需額外裝置 綁定一台裝置,裝置重置會遺失憑證
跨平台 YubiKey、Titan 安全金鑰、手機作為 Passkey 可攜、可備份 需要額外硬體,可能遺失

Passkeys 解決了可攜性問題

Passkeys 擴展了 WebAuthn,透過平台帳戶(iCloud Keychain、Google Password Manager)跨裝置同步憑證。這讓它對消費者變得可行:

傳統 WebAuthn:
  - 私鑰停留在一台裝置上
  - 遺失裝置 = 失去帳戶存取權

Passkeys(同步版):
  - 私鑰在雲端端對端加密
  - 所有登入同一平台帳戶的裝置均可使用
  - 備份和復原由作業系統內建完成

我們儲存的 credentialBackedUp 標記告訴我們憑證是否同步。這對復原流程很重要:同步的憑證可以經受住裝置遺失。


安全考量

防重放攻擊

challenge + 簽名計數器模式阻止重放攻擊:

// 驗證時檢查計數器
if (authenticationInfo.newCounter <= credential.counter) {
  throw new Error("偵測到可能的重放攻擊");
}

每次認證計數器遞增。如果簽名被重放,計數器沒有推進,驗證就會失敗。

防釣魚

WebAuthn 的防釣魚能力來自來源綁定。瀏覽器在建立憑證時將 rpId 納入其中。認證時,它會驗證請求來源是否匹配。goog1e.com 上的釣魚網站無法使用為 google.com 建立的憑證。

// 伺服器強制執行來源檢查
const verification = await verifyAuthenticationResponse({
  // ...
  expectedOrigin: RP_ORIGIN, // "https://myapp.com"
  expectedRPID: RP_ID,       // "myapp.com"
});

使用者驗證等級

等級 描述 使用者體驗
discouraged 無需使用者手勢 即時回應,安全性較低
preferred 有則要求,無則跳過 最平衡的選擇
required 必須驗證(生物辨識/PIN) 最安全,部分裝置可能不支援
// 敏感操作(支付、管理後台)要求驗證
const options = await generateAuthenticationOptions({
  userVerification: "required",
});

// 普通登入,preferred 是好的預設值
const options = await generateAuthenticationOptions({
  userVerification: "preferred",
});

條件 UI(自動填充)模式

最好的 Passkey 體驗根本不需要「使用 Passkey 登入」按鈕。條件 UI(之前叫 autofill)在使用者點選使用者名稱欄位時就展示 passkey 選擇器:

// 頁面載入時,啟動條件中介
useEffect(() => {
  const abortController = new AbortController();

  const options = await fetch("/api/webauthn/login/options", {
    method: "POST",
    body: JSON.stringify({}),
  }).then((r) => r.json());

  // 這裡不會彈窗——它等待使用者與輸入欄位互動
  startAuthentication(options, true) // true = useBrowserAutofill
    .then((result) => {
      // 使用者選擇了 passkey
      verifyAndLogin(result);
    })
    .catch((err) => {
      if (err.name !== "AbortError") {
        console.error("條件 UI 錯誤:", err);
      }
    });

  return () => abortController.abort();
}, []);

當使用者點選使用者名稱欄位時,瀏覽器會將可用的 passkeys 顯示為自動填充建議。點一下、掃個臉、登入成功。這就是讓 Passkeys 真正優於密碼的「魔法」體驗。


復原流程:使用者丟了所有裝置怎麼辦?

WebAuthn 的優勢也是它的弱點:私鑰綁定在裝置上。復原需要提前規劃:

策略一:多憑證

鼓勵使用者註冊多個憑證:

// UI:"新增另一個 Passkey"
// 每個憑證都可以獨立使用
// 註冊一個平台 passkey(iCloud/Google 同步)+ 一個跨平台金鑰(YubiKey)

策略二:復原碼

// 註冊時,生成 10 個一次性復原碼
const recoveryCodes = Array.from({ length: 10 }, () =>
  randomBytes(8).toString("hex")
);

const hashedCodes = await Promise.all(
  recoveryCodes.map((code) => bcrypt.hash(code, 12))
);

await db.recoveryCodes.createMany({
  data: hashedCodes.map((hash) => ({ userId, hash })),
});

// 只展示一次,使用者自行儲存
return { recoveryCodes };

策略三:兜底到已驗證的郵箱/手機

// 如果沒有可用的 passkey:
// 1. 發送魔法連結到已驗證的郵箱
// 2. 點選後,為當前裝置建立新的 passkey
// 3. 撤銷所有舊憑證(可選,為了安全)

生產環境檢查清單

# 檢查項 原因
1 RP_ID 必須匹配域名 不匹配 = 所有操作失敗
2 全站 HTTPS(開發環境 localhost 例外) WebAuthn 需要安全上下文
3 Challenge TTL ≤ 5 分鐘 防止 challenge 復用
4 追蹤並驗證 signCount 防止重放攻擊
5 @simplewebauthn 解析資料 CBOR/ASN.1 解析容易出錯
6 用 base64url 儲存 publicKey(不要用 hex) SimpleWebAuthn 期望這個格式
7 設定 residentKey: "required" 啟用 Passkeys 可發現憑證的必要條件
8 優雅處理 InvalidStateError 使用者取消——不算錯誤
9 在真實硬體上測試 模擬器沒有平台認證器
10 遷移期間保留密碼登入兜底 「不要破壞現有使用者體驗」
11 記錄證明失敗日誌用於監控 檢測攻擊嘗試
12 對 challenge 端點做速率限制 防止 DoS

常見錯誤與解決方案

錯誤 原因 解決方案
NotAllowedError: The operation timed out 使用者取消,或沒有平台認證器可用 顯示「重試」按鈕;檢查 PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
SecurityError: The operation is insecure 非 HTTPS,或 localhost 配置不正確 驗證 HTTPS 憑證;開發環境新增 localhost 例外
UnknownError: An unknown error occurred 選項和驗證之間的 RP ID 不匹配 確保 generate 和 verify 呼叫中的 rpID 完全一致
InvalidStateError: The authenticator was previously registered 重複註冊憑證 在註冊選項中傳入 excludeCredentials
ConstraintError 試圖註冊已存在的憑證 捕獲並優雅處理——提示使用已有 passkey
瀏覽器不顯示 passkey 自動填充 allowCredentials 為空或有誤,條件中介不支援 {}(空選項、無 allowCredentials)走可發現流程

資料庫 Schema

CREATE TABLE credentials (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  credential_id TEXT NOT NULL UNIQUE,
  public_key TEXT NOT NULL,
  counter INTEGER NOT NULL DEFAULT 0,
  device_type TEXT NOT NULL,      -- 'singleDevice' | 'multiDevice'
  backed_up BOOLEAN NOT NULL DEFAULT false,
  transports TEXT,                 -- 'usb,ble,nfc,internal'
  created_at TIMESTAMP DEFAULT NOW(),
  last_used_at TIMESTAMP,
  name TEXT                        -- 使用者命名:"我的 iPhone" / "YubiKey 5"
);

CREATE TABLE challenges (
  id TEXT PRIMARY KEY,
  user_id TEXT,
  challenge TEXT NOT NULL,
  expires_at TIMESTAMP NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_credentials_user_id ON credentials(user_id);
CREATE INDEX idx_credentials_credential_id ON credentials(credential_id);
CREATE INDEX idx_challenges_expires_at ON challenges(expires_at);

總結:WebAuthn 和 Passkeys 在協定層面消除了釣魚和憑證填充攻擊。核心模式很簡單:瀏覽器生成密鑰,伺服器只儲存公鑰,認證就是一次密碼學簽名操作。複雜性在細節中——證明驗證、計數器檢查、challenge 管理、復原流程。新專案建議 Passkeys-only 認證加復原碼。已有專案建議在密碼旁新增 Passkeys,逐步提高採用率。安全提升是質變的:憑證復用和釣魚變得在設計上就不可行,而非靠策略約束。


線上工具

本站提供瀏覽器本地工具,免註冊即可試用 →

#WebAuthn#Passkeys#FIDO2#无密码认证#安全#认证#2026