當未經驗證的使用者點選「登入」連結、開始結帳、將商品加入收藏,或遇到文章付費牆時,如果將他們導向專屬登入頁面,會導致情境中斷,並增加使用者流失率。即時 UI 模式 (navigator.credentials.get() 中的 uiMode: 'immediate') 可在裝置上有本機密碼金鑰 (publicKey) 或已儲存的密碼 (password: true) 時,立即顯示統一的瀏覽器帳戶選擇工具,讓使用者快速登入。
標準模態 WebAuthn 提示一律會顯示瀏覽器 UI,並改用跨裝置 QR code 或外部安全金鑰。與之不同的是,如果裝置本機沒有符合資格的憑證,立即 UI 模式會快速失敗,並傳回 NotAllowedError。這樣一來,應用程式就能讓回訪者輕觸一下,在原地完成驗證,同時順暢地將沒有本機憑證的使用者,轉移到標準登入頁面、訪客結帳頁面或內嵌登入表單。
建議的使用者體驗模式和反模式
在使用者意圖明確時,就在離開目前頁面前,於使用者啟動的事件處理常式 (需要暫時使用者啟用) 內觸發「立即」UI 模式。如果立即驗證成功,請立即完成使用者的意圖 (無論是更新目前頁面,還是直接前往已驗證的目的地)。如果失敗並顯示 NotAllowedError,請顯示登入或註冊流程,並提供完成使用者意圖的路徑。
可能的應用情境:
- 攔截標頭登入連結或按鈕:攔截對全域「登入」連結 (
event.preventDefault()) 的點擊,立即驗證回訪者,或繼續前往NotAllowedError的專屬登入頁面。 - 結帳前動態登入:回訪購物者點選「前往結帳」時,輕觸一下即可驗證身分,直接進入運送和付款步驟,或在備用方案中轉換至標準登入或訪客結帳畫面。
- 微動作 (加入收藏、按讚、加入書籤、儲存) 的內嵌登入:當未登入的使用者點選微動作按鈕時,在原處進行驗證並立即完成動作 (例如切換
aria-pressed="true"),或開啟備用登入模式或含有返回網址參數的登入頁面。 - 文章內軟付費牆:讀者點選付費牆橫幅上的「使用帳戶繼續閱讀」時,系統會立即驗證讀者身分,或在備用廣告上展開內嵌登入或註冊表單。
應避免的反面模式:
- 請勿在專屬登入頁面上使用即時 UI 模式:請勿將
uiMode: 'immediate'附加至最終到達網頁登入頁面上的主要「登入」或「使用密碼金鑰登入」按鈕。使用者在目前裝置上沒有本機憑證,或打算使用跨裝置密碼金鑰 (用手機掃描 QR code) 或外部實體安全金鑰時,就會前往專屬登入頁面。由於uiMode: 'immediate'會隱藏跨裝置 QR code 和安全金鑰選項,並在沒有本機憑證時立即擲回NotAllowedError,因此在專用登入頁面上使用該方法會導致使用者陷入死胡同,點選按鈕後不會有任何反應。在專屬登入頁面上,使用表單輸入內容的條件式 UI (mediation: 'conditional'搭配autocomplete="username webauthn"),並搭配標準互動視窗navigator.credentials.get({ publicKey })按鈕 (請參閱「密碼金鑰驗證」)。 - 請勿使用即時 UI 模式進行升級重新驗證:請勿使用
uiMode: 'immediate'重新驗證已登入的使用者,再執行敏感操作 (例如變更密碼、更新運送地址、編輯儲存的付款方式,或新增密碼金鑰)。重新驗證時,必須透過非空白的allowCredentials清單,將提示限制為已登入使用者的憑證,即時 UI 模式會拒絕NotAllowedError,而省略allowCredentials會讓使用者不小心選取與目前登入帳戶不同的帳戶。如要進行升級重新驗證,請按照密碼金鑰重新驗證一文的說明操作。
隱私權和安全性限制
要求 uiMode: 'immediate' 時,瀏覽器會強制執行嚴格的隱私權和安全性規則:
- 必須暫時啟用使用者:您必須在使用者啟動的事件處理常式 (例如
<button>或<a>元素上的click事件監聽器) 中呼叫navigator.credentials.get({ uiMode: 'immediate' }),防止系統在背景探測憑證。請勿在載入網頁時自動叫用 (DOMContentLoaded)。呼叫 API 不會耗用暫時啟用。 - 請勿傳遞非空白的
allowCredentials清單:指定uiMode: 'immediate'時,請勿填入publicKey.allowCredentials。瀏覽器會立即拒絕任何allowCredentials清單不為空的立即 UI 模式要求,並擲回NotAllowedError,因此依賴方無法探測特定憑證 ID,推斷先前的造訪記錄或跨工作階段追蹤使用者。 - 請勿傳遞
AbortSignal(signal):指定uiMode: 'immediate'時,請勿在navigator.credentials.get()上設定signal屬性,因為瀏覽器不允許以程式輔助方式關閉立即登入對話方塊。如果網頁也執行背景條件式 UI (mediation: 'conditional') 要求,請先對該條件式 UIAbortController呼叫abort(),再叫用即時 UI 模式,並從即時要求中省略signal。 - 無痕模式和私密瀏覽工作階段:在無痕模式或私密瀏覽視窗中提出的要求一律會遭到拒絕,並傳回
NotAllowedError,因此私密工作階段與沒有本機憑證的工作階段仍無法區分。
伺服器端設定
選項生成端點
公開端點,為未經驗證的訪客產生可供偵測的 WebAuthn 要求選項:
- 產生工作階段繫結驗證碼:建立加密隨機驗證碼緩衝區,儲存在伺服器工作階段中,並編碼為 Base64URL。
- 保留使用者驗證偏好設定:設定
userVerification: 'preferred'並將expectedUserVerification: 'preferred'儲存在工作階段中。 - 省略
allowCredentials:請勿在即時 UI 模式的選項中加入非空白的allowCredentials清單,因為瀏覽器會拒絕含有NotAllowedError的非空白許可清單。
// Server-side options generation example (/api/login/options)
router.post('/api/login/options', (req, res) => {
const options = {
challenge: serverGeneratedBase64UrlChallenge, // High-entropy random challenge stored in session
rpId: 'example.com',
userVerification: 'preferred',
};
req.session.challenge = options.challenge;
req.session.expectedUserVerification = 'preferred';
return res.json(options);
});
雙重憑證驗證端點
由於 navigator.credentials.get({ password: true, publicKey, uiMode: 'immediate' }) 可使用密碼金鑰 (PublicKeyCredential) 或已儲存的密碼 (PasswordCredential) 解析,因此請為這兩種憑證類型提供驗證處理常式:
- WebAuthn 斷言驗證 (
/api/login/verify):根據工作階段驗證問題和儲存的公開金鑰驗證斷言。如果在資料庫中找不到憑證 ID,請傳回 HTTP 狀態404,讓用戶端透過PublicKeyCredential.signalUnknownCredential()通知密碼管理工具。 - 密碼驗證 (
/api/login/password):根據使用者資料庫驗證username(cred.id) 和password(cred.password),並建立已驗證的工作階段。
用戶端導入
統一憑證要求
- 同時要求密碼金鑰和密碼:使用
PublicKeyCredential.parseRequestOptionsFromJSON()解碼伺服器選項,並在使用者啟動的點擊處理常式中,使用password: true、publicKey和uiMode: 'immediate'呼叫navigator.credentials.get()(不傳遞signal)。 - 由
cred.type驗證分支版本:- 如果
cred.type === 'public-key',請使用cred.toJSON()序列化斷言,並將其 POST 到 WebAuthn 驗證端點。當伺服器回應 HTTP404時,請呼叫PublicKeyCredential.signalUnknownCredential({ rpId: publicKey.rpId, credentialId: encoded.id })。 - 如果
cred.type === 'password',請將{ username: cred.id, password: cred.password }POST 到密碼驗證端點。
- 如果
- 在本機憑證不存在時處理
NotAllowedError:擷取NotAllowedError(當裝置上沒有本機密碼金鑰或已儲存的密碼,或使用者關閉選擇器時,系統會立即擲回),並轉換至標準登入流程 (例如開啟內嵌登入對話方塊,或使用傳回網址前往/signin)。
let isAuthenticating = false;
async function authenticateWithImmediateUi() {
const optionsResponse = await fetch('/api/login/options', { method: 'POST' });
if (!optionsResponse.ok) {
throw new Error('Failed to fetch authentication options');
}
const optionsJSON = await optionsResponse.json();
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON);
// MANDATORY: Request both password and publicKey with uiMode: 'immediate' and NO signal parameter
const cred = await navigator.credentials.get({
password: true,
publicKey,
uiMode: 'immediate',
});
if (!cred) {
throw new DOMException('No credential returned', 'NotAllowedError');
}
if (cred.type === 'public-key') {
const encoded = cred.toJSON();
const verifyResponse = await fetch('/api/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(encoded),
});
if (verifyResponse.status === 404) {
await PublicKeyCredential.signalUnknownCredential({
rpId: publicKey.rpId || window.location.hostname,
credentialId: encoded.id, // Base64URL-encoded credential ID string
});
throw new Error('Passkey credential is no longer recognized by the server');
}
if (!verifyResponse.ok) {
throw new Error('Passkey verification failed');
}
return await verifyResponse.json();
}
if (cred.type === 'password') {
const verifyResponse = await fetch('/api/login/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: cred.id,
password: cred.password,
}),
});
if (!verifyResponse.ok) {
throw new Error('Password verification failed');
}
return await verifyResponse.json();
}
throw new Error(`Unsupported credential type: ${cred.type}`);
}
// Example: Intercepting an inline micro-action (Favorite button) or Header Sign-in click
const favoriteButton = document.getElementById('favorite-btn');
favoriteButton.addEventListener('click', async (event) => {
event.preventDefault();
if (isAuthenticating) return;
// If already signed in, toggle the micro-action state immediately
if (document.body.dataset.signedIn === 'true') {
const nextPressed = favoriteButton.getAttribute('aria-pressed') !== 'true';
favoriteButton.setAttribute('aria-pressed', String(nextPressed));
return;
}
isAuthenticating = true;
try {
const user = await authenticateWithImmediateUi();
// Complete the user's intended action immediately
document.body.dataset.signedIn = 'true';
favoriteButton.setAttribute('aria-pressed', 'true');
updateHeaderUserState(user);
} catch (err) {
if (err.name === 'NotAllowedError') {
// No local credentials available or user dismissed dialog:
// transition smoothly to fallback sign-in UI or redirect with return URL
showFallbackSignInUi({ returnAction: 'favorite' });
return;
}
console.error('Authentication error:', err);
} finally {
isAuthenticating = false;
}
});
備用策略
立即改用 UI 模式
- 漸進增強體驗:將
uiMode: 'immediate'視為漸進增強。請勿根據瀏覽器的名稱或版本推斷是否支援,唯一可靠的支援信號是透過PublicKeyCredential.getClientCapabilities()進行capabilities.immediateGet執行階段檢查。必要:呼叫navigator.credentials.get({ uiMode: 'immediate' })前,請務必檢查capabilities.immediateGet。無法辨識uiMode: 'immediate'的瀏覽器會忽略未知的字典鍵,並開啟封鎖模式的密碼金鑰和 QR code 對話方塊。如果capabilities.immediateGet為 falsy,或navigator.credentials.get()拒絕NotAllowedError,請改用應用程式的標準登入體驗 (例如開啟內嵌登入模式,或使用傳回網址參數前往專屬登入頁面),讓不支援瀏覽器的使用者看到現有的登入流程不變:
async function isImmediateUiSupported() {
if (!window.PublicKeyCredential || !PublicKeyCredential.getClientCapabilities) {
return false;
}
try {
const capabilities = await PublicKeyCredential.getClientCapabilities();
return Boolean(capabilities?.immediateGet);
} catch {
return false;
}
}
async function authenticateWithImmediateUi() {
// MANDATORY: Check immediateGet support so unsupported browsers do not open a modal QR dialog
const supported = await isImmediateUiSupported();
if (!supported) {
// Throw NotAllowedError so the caller's single catch path treats "unsupported" like "no local credentials"
throw new DOMException('Immediate UI mode is not supported', 'NotAllowedError');
}
// Proceed with fetching options and calling navigator.credentials.get({ password: true, publicKey, uiMode: 'immediate' })
}
用戶端功能偵測備用機制
請務必安裝 'webauthn-polyfills' 並匯入用戶端模組,以便在支援 PublicKeyCredential 時使用 PublicKeyCredential.getClientCapabilities():
import 'webauthn-polyfills';
密碼憑證備用機制
在 navigator.credentials.get() 中同時傳遞 password: true 和 publicKey,可讓支援的瀏覽器在統一的即時帳戶選擇工具中,納入瀏覽器密碼管理工具儲存的密碼。
- 備用體驗:不支援
PasswordCredential的瀏覽器會忽略password: true選項,或透過capabilities.immediateGet功能檢查,改用標準登入表單,其中<input autocomplete="username webauthn">和<input type="password" autocomplete="current-password">提供原生密碼自動填入功能。
輕鬆備用 JSON 序列化
請一律安裝 'webauthn-polyfills',並將其匯入用戶端套件,讓 PublicKeyCredential.parseRequestOptionsFromJSON() 和 PublicKeyCredential.prototype.toJSON() 在支援 PublicKeyCredential 的瀏覽器中運作一致:
import 'webauthn-polyfills';
Signal API 同步處理備用方案
WebAuthn Signal API (webauthn-signals) 是一項漸進增強功能,可在伺服器回應 HTTP 404 時,從使用者的密碼管理工具中移除過時的密碼金鑰。
- 備用體驗:在
if (PublicKeyCredential.signalUnknownCredential)後方閘道呼叫。如果系統不支援,請略過信號呼叫,並顯示清楚的訊息,引導使用者改用其他方式登入。