Passkeys (WebAuthn) for V2 Widgets
Passkeys let your users verify with the biometric they already use to unlock their device — Face ID, Touch ID, Windows Hello, or a screen lock — instead of waiting for a one-time code. Built on the WebAuthn standard and layered directly onto the V2 Widget you already know.
Instant verification — no code to wait for. When a returning user has a passkey on their device, the widget skips OTP delivery entirely: one biometric prompt and they're verified.
This page is the V2 Widget path — passkeys also work on V1.2
Passkeys run on both Akedly surfaces. This guide covers the V2 Widget, where
passkeys ride the widget's existing attempt lifecycle. If you integrate over the
V1.2 REST API, use the dedicated
V1.2 Passkeys guide for the six endpoint contracts,
NO_PASSKEY fallback, SDK links, result proof, and backend callback. The legacy V1.0
REST API issues OTPs only.
Recommended, backward-compatible, and opt-in
Passkeys are the recommended path forward for V2 Widgets. They are fully backward-compatible — if you change nothing, your widget keeps running OTP exactly as it does today. Turn passkeys on when you're ready, one iframe attribute at a time.
Why passkeys
Faster — instant. No SMS or WhatsApp round-trip. A returning user with a passkey on their device verifies in a single biometric prompt, with nothing to type and no code to wait for.
Higher conversion. Removing the "wait for a code, switch apps, copy, paste" loop eliminates the biggest source of drop-off in phone verification.
More secure. Passkeys are device-bound public-key credentials. There is no shared secret to phish, intercept, or replay — the private key never leaves the user's device.
Lower cost. A successful passkey authentication is billed at 60% of your OTP rate — so you save up to 40% per authentication. Enrollment is free, and only a successful authentication is ever charged.
Save up to 40% per authentication
When a user verifies with a passkey instead of an OTP, you pay 60% of your OTP rate for that authentication — a saving of up to 40%. Enrolling a passkey is free, and you are only billed on a successful passkey authentication. OTP authentications are unchanged.
Already using V2? Here's the one change
If you already run the V2 Widget, enabling passkeys is a single attribute on your iframe. Add the allow attribute so the embedded widget is permitted to invoke WebAuthn:
allow="publickey-credentials-get *; publickey-credentials-create *"
That's it. Here is the before → after for each common embed.
Add the allow attribute
<!-- Before -->
<iframe src="https://auth.akedly.io/auth?attemptId=..."></iframe>
<!-- After -->
<iframe
src="https://auth.akedly.io/auth?attemptId=..."
allow="publickey-credentials-get *; publickey-credentials-create *"
></iframe>
Prefer to scope the permission?
The wildcard * delegates WebAuthn to the iframe's origin. If you'd rather
scope it explicitly to Akedly's auth origin, use:
allow="publickey-credentials-get https://auth.akedly.io; publickey-credentials-create https://auth.akedly.io"
That's the only change — nothing else moves
Do nothing and you keep OTP. The iframe URL stays
…/auth?attemptId=… (only attemptId is required) — there are no new
routes or parameters. Your postMessage, redirect, and webhook
contracts are unchanged. There is nothing to do server-side:
auth.akedly.io already serves the required Permissions-Policy and CSP
frame-ancestors headers, so you only ever touch your own <iframe> tag.
How it works (end-user flow)
The passkey experience is woven into the existing widget flow. The user is never trapped on a passkey screen — a code is always one tap away.
- Captcha. The user passes the standard Cloudflare Turnstile check, exactly as today.
- Returning user with a passkey on this device. If the bound phone number already has a passkey on this device, the widget offers an optional "Use passkey" button. A "Use a code instead" option is always present alongside it.
- Everyone else. If there's no passkey for this device, the widget goes straight to normal OTP — no extra screens.
- Success. A passkey verification completes the attempt just like an OTP verification: same redirect, same webhook, same postMessage.
- After an OTP success. Once a user verifies with a code, the widget may show an optional, skippable "Enable passkey" prompt so their next verification can be instant.
Nobody ever gets trapped
If passkeys are unsupported on the device or browser, the user cancels, or the account isn't entitled, the widget silently falls back to OTP. There is no dead end and no error shown to the user.
New integrator guide
New to Akedly? Don't start here. Passkeys are an enhancement of the V2 Widget, not a standalone product — set the widget up first, and passkeys ride on the same attempt lifecycle with no extra steps.
Start on the V2 Widget guide
The complete setup — create a widget in the dashboard, create a server-side
attempt with an HMAC signature, receive an iframeUrl, and open the iframe —
lives on the V2.0 Widget SDK page, which now includes a
dedicated Passkeys section covering exactly what
to add for each embed surface. Build your V2 integration there with the allow
attribute from day one, and passkeys light up automatically once your account
is enabled and the pipeline's passkeyEnabled toggle is on — this page is
the deeper passkey reference you return to afterward.
Handling the result
The result contracts are identical to standard V2 — consumers should key only on type === "AUTH_SUCCESS" and tolerate extra optional fields. A passkey success may add one optional field, verificationMethod: "passkey".
postMessage
- Name
type- Type
- string
- Description
"AUTH_SUCCESS". Key on this and nothing else — tolerate any extra optional fields.
- Name
attemptId- Type
- string
- Description
The original
attemptIdyou created.
- Name
transactionId- Type
- string
- Description
The transaction identifier for this verification.
- Name
redirectUrl- Type
- string
- Description
Optional. Present when a frontend callback URL is configured.
- Name
timestamp- Type
- string
- Description
ISO 8601 timestamp of when authentication completed.
- Name
verificationMethod- Type
- string
- Description
Optional.
"passkey"when the user verified with a passkey. Absent for OTP.
postMessage (passkey success)
{
"type": "AUTH_SUCCESS",
"attemptId": "attempt_a1b2c3d4e5f6...",
"transactionId": "pkreq_9f8e7d6c5b4a...",
"redirectUrl": "https://yourapp.com/auth/callback?status=success&...",
"timestamp": "2026-01-16T12:05:30.123Z",
"verificationMethod": "passkey"
}
Redirect
The success redirect query string is unchanged:
…?status=success&transactionId=…&attemptId=…×tamp=…&meta_*
Signed webhook
The webhook envelope is byte-compatible with the standard V2 webhook — same headers, same signing, same shape. For passkey verifications, note the following:
- Name
transactionId- Type
- string
- Description
For a passkey verification this is an opaque passkey request id, not an OTP transaction id. Treat it as opaque — store it and echo it back, but don't parse or assume its format.
- Name
verificationMethod- Type
- string
- Description
Optional.
"passkey"when the user verified with a passkey.
- Name
channel- Type
- string
- Description
Absent for passkey verifications — there is no OTP delivery channel (WhatsApp / SMS / Email) involved. Don't depend on this field being present.
Verify the signature — unchanged
Passkey webhooks ship with the same HMAC-SHA256 svix-id, svix-timestamp,
and svix-signature headers as every other backend callback. Verify the
signature on the raw request bytes before trusting the payload, exactly as you
do for OTP. See the webhook signing guide →
Platforms & surfaces
What you edit depends on how you embed the widget. The short version: web iframes add the allow attribute, full-page redirects need nothing, and mobile native apps must open the widget in a system browser — not a WebView (see below).
When passkeys appear
Passkeys are generally available — there is no waitlist and nothing to request. Three gates decide whether the widget offers them, and only the last one is yours to set:
- Name
Passkeys released- Type
- Akedly-controlled
- Description
- A global switch we hold, and it is on. Passkeys are generally available — not an enterprise tier, not early access, and nothing to join.
- Name
Account enabled- Type
- entitlement
- Description
- On by default, for every account. There is nothing to request. If passkeys are ever switched off for a particular account that is a deliberate exception, and you would know about it.
- Name
Pipeline passkeys on- Type
- toggle
- Description
- Each pipeline carries a
passkeyEnabledtoggle, and in practice this is the only gate you set. New pipelines arrive with it on. Every pipeline you already have keeps exactly the setting it has right now — if passkeys were on for it, they stay on; if you had turned them off, they stay off. Turn it off on any pipeline you want to keep OTP-only, and it stays off.
Turn it on when you're ready
Everything here is fully backward-compatible. While the pipeline's
passkeyEnabled toggle is off, the widget runs OTP exactly as it does today.
Add the allow attribute now, and passkeys light up the moment that toggle is
on — no further code changes needed.
Passkeys on V1.2
V1.2 passkeys now have a dedicated guide.
FAQ & gotchas
Local vs on-device testing
Where the ceremony runs — and therefore the relying-party (RP) ID the passkey binds to — is
controlled by ceremonyOrigin:
- Established local QA points the ceremony at the shared-cert dev auth-gateway origin
(
https://localhost:5174), binding to RP=localhost, which iOS Simulators and Android emulators accept. If the shared certificate files are absent, the dev server falls back to HTTP; configureceremonyOriginto the exact origin the server prints rather than mixing HTTP and HTTPS. - Production / real devices use
https://auth.akedly.io, binding to RP=akedly.io.
Per-platform prerequisites for the local loop: iOS Simulator → iOS 16+ and signed into iCloud; Android emulator → Google Play Services plus a configured screen lock (Credential Manager refuses without one); Web → any WebAuthn-capable browser.
Android emulator: forward the host's dev ports
An Android emulator's localhost is the emulator, not your machine (the host loopback is
reachable only via the emulator's 10.0.2.2 alias), so it can't reach the dev auth-gateway
(:5174) or backend (:3000) at localhost. Run
adb reverse tcp:5174 tcp:5174 && adb reverse tcp:3000 tcp:3000 so ceremonyOrigin stays
https://localhost:5174 in the shared-cert setup — the dev RP is localhost, so the ceremony
must be reached at a
localhost origin (an IP like 10.0.2.2 cannot be a WebAuthn RP ID and isn't an allowed dev
origin). If the gateway explicitly starts in its certificate-absent HTTP fallback, use
http://localhost:5174 consistently instead. The iOS Simulator shares the host network, so
localhost works there with no forwarding.
A localhost RP cannot bind on a physical device
Real-device QA must use the https://auth.akedly.io origin (RP=akedly.io) — a localhost
RP only works on simulators and emulators. A full end-to-end V1.2 sandbox (web, the mobile SDK
reference apps, and a Playwright + Chrome virtual-authenticator gate) exists for headless
passkey QA without a physical device.
Support & resources
- V2.0 Widget SDK guide: /authentication/v2
- Webhook signing guide: /webhooks
- Dashboard: https://app.akedly.io
- Support: support@akedly.io