I recently added passkeys to a member login page, and LINE's in-app browser gave me one of the sneakiest bugs I've dealt with in a while. The passkey button showed up. Tapping it did nothing. No system prompt, no error, nothing in the console.
This post walks through what was going on, plus three things I think anyone shipping passkeys should know: how to tell whether passkeys will actually work, why NotAllowedError doesn't mean the user cancelled, and why a null from your backend can make Safari throw before the ceremony even starts. All the code is ready to copy. If your site gets opened from inside LINE, Facebook, Instagram or similar apps, this probably applies to you.
The symptom
While testing on staging, I opened the login page inside LINE. The passkey button was there, but tapping it did nothing at all.
With no error to look at, I started with a screen recording and stepped through it frame by frame. The "verifying" state showed up for exactly one frame at 30fps. So navigator.credentials.get() was being called, and it was failing within a few dozen milliseconds. Why was the screen blank? Because our code treated every NotAllowedError as "the user cancelled", and a cancel shouldn't show an error. The failure was being swallowed.
Next I added logging on a real device. On iOS 26.6 with LINE 26.12.1, I got this:
supported() = true // the shallow check passes
IUVPAA = false (12ms) // the platform honestly says no
get() -> NotAllowedError after 4ms
Nobody cancels anything in 4ms. The system prompt hadn't even been drawn yet. LINE's WebView exposes the WebAuthn API, but it doesn't actually let you use it. That's decided by the host app and the OS, and nothing on the page can change it.
1. Checking that the API exists isn't enough
A lot of tutorials decide whether to offer passkeys like this:
if (window.PublicKeyCredential) {
// show the passkey button
}
That's fine in a regular browser. In an in-app browser it isn't. The WebView usually runs on the system's browser engine, so the API is there, but whether a ceremony can actually run is up to the app wrapping it.
The more reliable approach is to ask the platform whether it can do this right now. There are two APIs for that:
-
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()asks whether the device has a platform authenticator that can verify the user (Face ID, a fingerprint reader, Windows Hello). It's widely supported. -
PublicKeyCredential.getClientCapabilities()is newer, from WebAuthn Level 3, and returns a whole set of capabilities at once. It needs Chrome or Edge 133, Firefox 135, or Safari 17.4.
Inside LINE, the first one honestly returns false. Use it to decide whether to show the passkey option, and the dead button goes away. Here's the version I ended up with:
const PROBE_TIMEOUT_MS = 3000;
let capability: Promise<boolean> | undefined;
// Cached, so the page only asks once
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;
// Newer API: prefer it when it's there
if (typeof PKC.getClientCapabilities === 'function') {
const caps = await withTimeout(PKC.getClientCapabilities(), PROBE_TIMEOUT_MS);
if (typeof caps?.userVerifyingPlatformAuthenticator === 'boolean') {
return caps.userVerifyingPlatformAuthenticator;
}
}
// Older API: much wider support
if (typeof PKC.isUserVerifyingPlatformAuthenticatorAvailable !== 'function') {
return false;
}
return (
(await withTimeout(
PKC.isUserVerifyingPlatformAuthenticatorAvailable(),
PROBE_TIMEOUT_MS,
)) === true
);
}
// Some restricted WebViews return a promise that never settles
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)),
]);
}
A few notes on why it looks like this:
- The timeout isn't optional. Some restricted WebViews hand back a promise that never resolves or rejects. Without a timeout, your login page just hangs on the check.
- Cache it. Login, enrollment and passkey management all need the answer, so ask once.
-
getClientCapabilities()can returnundefinedfor a key, which means the browser doesn't know. In that case it falls back to the older API. In my own project I only use the older API, and I've confirmed it returnsfalseinside LINE. I haven't verified what the newer one returns across different apps' WebViews, so test it on real devices before relying on it.
Why not just sniff the User-Agent for LINE? Because the platform already gives you an honest answer, and UA rules rot as apps ship new versions. If LINE ever enables passkeys, a UA check would keep those users locked out. The trade-off is that a desktop with only a USB security key and no Touch ID or Windows Hello gets filtered out too. For a phone-first product, I'm fine with that.
2. NotAllowedError doesn't mean the user cancelled
The reason the failure was invisible in the first place: the code mapped every NotAllowedError to "user cancelled". That's a really common pattern, but NotAllowedError covers a lot more than that. The user closing the prompt, the request timing out, no usable credential, and, as here, the environment refusing outright can all come back as the same error.
That's on purpose. If every failure had its own error, a site could use them to figure out whether a given credential exists on the device, and identify the user without their consent. So the browser won't tell you the exact reason, and you have to infer it.
The most useful signal turns out to be time. To cancel, a person has to see the system prompt and tap cancel. That can't happen in a few milliseconds. So I time the call, and anything rejected too quickly counts as "passkeys don't work here", and the user gets pointed to another way to sign in:
// Nobody can see a system prompt and dismiss it in under 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 });
}
}
On unavailable, show something like "Passkeys aren't supported here, try signing in with SMS". Only on cancelled should you stay quiet.
Where does 300ms come from? The rejection I measured in LINE took 4ms, so 300ms leaves about 75 times that margin and still sits well below how long a real person takes to cancel. It's a heuristic, though, not something the spec guarantees. It also only makes sense for the modal flow. If you use conditional mediation (passkeys in the autofill dropdown), there's no prompt to time, so don't apply it there.
In theory the check from section 1 already filters these environments out. This is the backstop for any WebView that lies about its capabilities, so you never ship another button that does nothing.
3. A null from the backend, a TypeError from Safari
Around the same time I hit a second passkey bug. During registration, Safari and Chrome on iOS 18 threw a TypeError before the system prompt ever appeared.
The backend was serializing unset WebAuthn fields as explicit null instead of leaving them out:
{
"authenticatorSelection": {
"residentKey": null,
"userVerification": "preferred"
}
}
In JavaScript, null and "not there" feel interchangeable. The browser doesn't see it that way. It converts your options into WebAuthn's types using WebIDL rules, and some of those fields don't accept null. An explicit null fails the conversion and throws, while a missing field just gets its default.
The fix is simple. Strip the nulls before handing the options to the browser:
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) });
The real fix is for the backend to stop sending nulls. But a small guard on the frontend means you don't have to worry about the next empty field someone adds.
If you really need passkeys inside LINE
Once you've detected that passkeys won't work, falling back to SMS or email isn't the only option. You can also send the user to their default browser. LINE supports a URL parameter, openExternalBrowser=1, that makes a link tapped inside LINE open in the external browser instead:
https://example.com/login?openExternalBrowser=1
It doesn't apply to LIFF apps, and it takes the user out of LINE, so whether it's worth it depends on your product.
Debugging inside in-app browsers
The hard part this time wasn't the fix. It was not being able to see the error. You can't attach DevTools to an in-app browser, so here's what helped:
-
Turn on eruda outside production. It drops a small console onto the page so you can read logs on the phone itself. Load it after the page's
loadevent so it doesn't interfere with what you're trying to observe. -
Log the environment first. On page load, dump the UA, the origin, whether
PublicKeyCredentialand friends exist, and what the capability check returned. That one set of numbers is what cracked this bug. - Count frames in a screen recording. Before you have any logging, step through a recording frame by frame to see how long a state actually lasts. A one-frame "verifying" state is what told me the call was failing, rather than the button not being wired up.
- Log the decision before you act on it. For the 300ms threshold, I first had the code just log what the threshold would have decided. Only after the numbers looked right on real devices did I let it change behavior.
Checklist
If your site gets opened inside in-app browsers, here's what to check when you ship passkeys:
- [ ] Use
getClientCapabilities()orisUserVerifyingPlatformAuthenticatorAvailable()to decide whether to show passkeys, not just whether the API exists - [ ] Put a timeout on the check and cache the result
- [ ] Don't treat every
NotAllowedErroras a cancel; give users a way out when it's rejected too fast - [ ] Strip
nullfields before callingnavigator.credentials - [ ] Test inside the actual apps on real devices; desktop emulation won't catch any of this
References:
Top comments (0)