英文版在 dev.to。
最近幫會員登入加上 Passkey,結果在 LINE 的內建瀏覽器裡踩到一個很隱蔽的問題:按鈕有出現,按下去沒反應,而且沒有任何錯誤。
這篇文章主要就是整理一下原因,還有三件我覺得每個做 Passkey 的人都應該知道的事:怎麼判斷「真的能用」、NotAllowedError 為什麼不能直接當成使用者取消,以及後端回傳 null 為什麼會讓 Safari 直接報錯。文中的程式碼都可以直接拿去用。如果你的服務會從 LINE、Facebook、Instagram 這類 App 裡被打開,應該都用得到。
在 staging 測試時發現,在 LINE 裡打開登入頁,Passkey 的按鈕有出現,但按下去什麼事都沒有:沒有系統的驗證視窗,也沒有任何錯誤訊息。
因為沒有錯誤可以看,我先從螢幕錄影下手,一格一格看。結果「驗證中」的畫面只出現了一格(30fps),代表 navigator.credentials.get() 有被呼叫,而且在幾十毫秒內就失敗了。那為什麼畫面上什麼都沒有?因為程式裡把 NotAllowedError 當成「使用者自己取消」,而使用者取消本來就不該跳錯誤,所以整個失敗就這樣靜靜地被吞掉了。
接著我在實機上加了 log(非正式環境會自動開啟 eruda,可以直接在手機上看 console),在 iOS 26.6、LINE 26.12.1 上得到這組數字:
supported() = true // 淺層檢查過了
IUVPAA = false (12ms) // 平台老實說自己不支援
get() -> NotAllowedError after 4ms
從上面可以看到,4 毫秒根本不可能是使用者取消,因為系統視窗都還沒畫出來。也就是說,LINE 的 WebView 雖然有提供 WebAuthn 的 API,但實際上不讓你用,而這是 App 和作業系統決定的,網頁這邊改什麼都沒用。
很多教學會這樣判斷支不支援 Passkey:
if (window.PublicKeyCredential) {
// 顯示 Passkey 按鈕
}
這在一般瀏覽器沒問題,但在 App 的內建瀏覽器裡就不一定了。因為 WebView 通常是直接用系統的瀏覽器核心,API 會在,但能不能真的跑驗證流程,是外層的 App 決定的。
所以比較可靠的做法是,直接問平台「你現在能不能用」。目前有兩個 API 可以問:
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable():問這台裝置有沒有可以做使用者驗證的平台驗證器(Face ID、指紋、Windows Hello 這類),支援度很廣。PublicKeyCredential.getClientCapabilities():WebAuthn Level 3 新加的 API,一次回傳一整組能力。Chrome / Edge 133、Firefox 135、Safari 17.4 以上才有。而在 LINE 裡,第一個 API 會老實回答 false,所以只要用它來決定要不要顯示 Passkey,就不會再出現按了沒反應的按鈕。下面是我整理過的版本:
const PROBE_TIMEOUT_MS = 3000;
let capability: Promise<boolean> | undefined;
// 結果會快取,整個頁面只問一次
export function canUsePasskey(): Promise<boolean> {
capability ??= probe();
return capability;
}
async function probe(): Promise<boolean> {
const PKC = window.PublicKeyCredential as any;
if (!PKC || !navigator.credentials?.get) return false;
// 新的 API:有的話優先用
if (typeof PKC.getClientCapabilities === 'function') {
const caps = await withTimeout(PKC.getClientCapabilities(), PROBE_TIMEOUT_MS);
if (typeof caps?.userVerifyingPlatformAuthenticator === 'boolean') {
return caps.userVerifyingPlatformAuthenticator;
}
}
// 舊的 API:支援度比較廣
if (typeof PKC.isUserVerifyingPlatformAuthenticatorAvailable !== 'function') {
return false;
}
return (
(await withTimeout(
PKC.isUserVerifyingPlatformAuthenticatorAvailable(),
PROBE_TIMEOUT_MS,
)) === true
);
}
// 有些受限的 WebView 回傳的 promise 永遠不會 settle,所以一定要加 timeout
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | undefined> {
return Promise.race([
p.catch(() => undefined),
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), ms)),
]);
}
幾個細節說明一下:
getClientCapabilities() 的值可能是 undefined,代表瀏覽器也不確定,這時候就退回用舊的 API。我自己的專案目前只有用舊的 API,在 LINE 裡測過確定會回 false;新的 API 在各家 App 的 WebView 裡會回什麼,建議自己在實機上確認。那為什麼不直接用 User-Agent 判斷是不是 LINE 就好?因為平台自己就會老實回答,而 UA 的規則會隨著 App 版本更新慢慢失效,哪天 LINE 開放了 Passkey,用 UA 擋掉的人也用不到。代價是只有外接安全金鑰、沒有 Touch ID 或 Windows Hello 的桌機也會被擋掉,但以手機為主的服務來說,這個取捨是可以接受的。
NotAllowedError 不等於使用者取消回頭看症狀那段,錯誤之所以會被吞掉,是因為程式裡把 NotAllowedError 一律當成「使用者取消」。這個寫法其實很常見,但 NotAllowedError 涵蓋的情況比想像中多:使用者關掉視窗、等太久 timeout、沒有可用的憑證,還有這次遇到的「環境直接拒絕」,都可能是同一個錯誤。
這是 WebAuthn 規格刻意的設計。因為如果每種失敗都回不同的錯誤,網站就可以藉此推測「這個人在這台裝置上有沒有某個憑證」,在使用者沒同意的情況下識別出他是誰。所以瀏覽器不會告訴你確切的原因,只能靠其他線索判斷。
而最好用的線索就是時間。使用者要取消,至少要等系統視窗畫出來、再去按取消,不可能在幾毫秒內發生。所以我在呼叫前後計時,太快被拒絕的就當成「這個環境不能用」,提示會員改用別的方式登入:
// 人不可能在 300ms 內看到系統視窗並按下取消
const HUMAN_CANCEL_FLOOR_MS = 300;
export type PasskeyFailure = 'cancelled' | 'unavailable' | 'error';
export async function getPasskeyAssertion(options: CredentialRequestOptions) {
const started = performance.now();
try {
return await navigator.credentials.get(options);
} catch (err) {
const elapsed = performance.now() - started;
console.warn(`[passkey] rejected after ${Math.round(elapsed)}ms`, err);
if (err instanceof DOMException && err.name === 'NotAllowedError') {
const reason: PasskeyFailure =
elapsed < HUMAN_CANCEL_FLOOR_MS ? 'unavailable' : 'cancelled';
throw Object.assign(new Error(reason), { reason });
}
throw Object.assign(new Error('error'), { reason: 'error' as PasskeyFailure });
}
}
拿到 unavailable 就顯示「這個環境不支援 Passkey,請改用簡訊登入」;拿到 cancelled 才是什麼都不說。
300ms 是怎麼來的?我實測 LINE 裡的拒絕只花了 4ms,300ms 大概是 75 倍的空間,而且還是遠低於真人取消會花的時間。不過要注意,這是經驗法則,不是規格保證的行為;而且只適用在會跳出系統視窗的流程,如果你用的是自動填入(conditional mediation)那種不跳視窗的登入,就不能這樣判斷。
理論上第一節的檢查已經會先擋掉,但萬一哪個 WebView 對自己的能力說謊,這一層至少可以確保不會再出現一個按了沒反應的按鈕。
null,Safari 直接 TypeError同一段時間還遇到另一個 Passkey 的問題:註冊時 iOS 18 的 Safari 和 Chrome 直接丟出 TypeError,連系統視窗都還沒出來就失敗了。
原因是後端把沒設定的 WebAuthn 欄位序列化成 null,而不是直接省略,例如:
{
"authenticatorSelection": {
"residentKey": null,
"userVerification": "preferred"
}
}
對 JavaScript 來說,null 跟「沒有這個欄位」好像差不多,但瀏覽器在把參數轉成 WebAuthn 的型別時,是照 WebIDL 的規則在轉的。而有些欄位在規格上不接受 null,所以「明確寫了 null」就會在轉換這一步丟 TypeError;但如果是「沒寫」,就會直接套用預設值。
這裡解法就相對簡單,在丟給瀏覽器之前,先把 null 的欄位拿掉:
function stripNulls<T>(value: T): T {
if (Array.isArray(value)) return value.map(stripNulls) as T;
if (value && typeof value === 'object' && !ArrayBuffer.isView(value) && !(value instanceof ArrayBuffer)) {
return Object.fromEntries(
Object.entries(value)
.filter(([, v]) => v !== null)
.map(([k, v]) => [k, stripNulls(v)]),
) as T;
}
return value;
}
await navigator.credentials.create({ publicKey: stripNulls(creationOptions) });
當然,最根本的解法是讓後端不要輸出 null。但前端自己擋一層,就不用擔心後端哪天又多一個空欄位。
偵測到不能用之後,除了退回簡訊登入,也可以引導會員改用手機的預設瀏覽器打開。LINE 有一個網址參數 openExternalBrowser=1,帶上它的連結在 LINE 裡點開時會直接用外部瀏覽器開,例如:
https://example.com/login?openExternalBrowser=1
不過 LIFF app 不適用,而且會把會員帶離 LINE,是不是值得要看服務的情況。
這次最麻煩的其實不是修,而是「看不到錯誤」。App 的內建瀏覽器沒辦法接 DevTools,所以我用了這幾個方法:
load 之後再載入,避免影響到你想觀察的行為。PublicKeyCredential 等 API 存不存在、偵測的結果全部印出來。後來整個問題就是靠這一組數字定位的。如果你的服務會在 App 內建瀏覽器裡被打開,做 Passkey 時可以對照這幾點:
getClientCapabilities() 或 isUserVerifyingPlatformAuthenticatorAvailable() 決定要不要顯示 Passkey,而不是只看 API 存不存在NotAllowedError 一律當成使用者取消,太快被拒絕的要給會員替代方案navigator.credentials 之前,先清掉 null 的欄位參考資料: