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 除錯用的 challenge 雜湊
- Base64 編解碼 — 處理 base64url 編碼的憑證 ID 和公鑰
- JSON 格式化 — 格式化和檢查 WebAuthn 選項與回應載荷
本站提供瀏覽器本地工具,免註冊即可試用 →