Passkeys on V2.0
Add passkeys to the widget so returning users verify with their device instead of a code.
Passkeys
Let returning users verify with the biometric that already unlocks their device — Face ID, Touch ID, Windows Hello, or a screen lock — instead of waiting for an OTP. Built on the WebAuthn standard and layered directly onto the V2 Widget you already know: same attempt lifecycle, same result contracts, one optional change to your embed. There is no shared secret to phish, intercept, or replay.
Recommended, opt-in, and backward-compatible
Successful passkey sign-ins are billed at the passkey rate: by default 60% of what an OTP would cost you for that phone number. Some accounts are on a fixed per-passkey rate instead. Enrollment is free. They're fully backward-compatible: change nothing and the widget keeps running OTP exactly as today. How passkeys work across apps, and what they cost, is on the Passkeys overview.
How passkeys work
Passkeys are woven into the existing widget flow — the user is never stranded on a passkey screen.
- Captcha. The user passes the standard Cloudflare Turnstile check, exactly as today.
- Returning user with a passkey on this device. If the bound phone already has a passkey on this device, the widget offers an optional "Use passkey" button. "Use a code instead" is always shown alongside it.
- Everyone else goes straight to normal OTP — no extra screens.
- Success is identical to OTP — same redirect, same webhook contract, same postMessage.
- After an OTP success, the widget may show an optional, skippable "Enable passkey" prompt so the next verification is instant.
V2 ceremonies ask the device for user verification as preferred; the V1.2 passkey ceremony requires it.
Nobody is ever trapped
If passkeys are unsupported on the device, the user cancels, or the account isn't entitled, the widget silently falls back to OTP — no dead end, no error shown. Adding passkey support can never break an existing integration.
These short clips show both passkey ceremonies from the user's side:
Verifying with a saved passkey
Returning-user passkey verification in the widget.
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. Build your V2
integration there with the allow attribute from day one, and passkeys light up
automatically once the current production rollout, your account entitlement,
and the pipeline's Passkeys setting are all on (the latter is stored as
pipeline.passkeyEnabled).
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.
<!-- 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.
Enabling passkeys for your account
Passkey availability depends on the current production configuration, account entitlement and pipeline settings. The checked-in backend defaults are not a promise about production access. Three gates decide whether the widget offers passkeys:
- Name
- Global rollout
- Type
- Akedly-controlled
- Description
- Passkeys are generally available. Akedly controls the global rollout switch.
- Name
- Account entitlement
- Type
- account access
- Description
- Granted automatically on the unverified-to-verified transition, so a verified account normally has it. The grant is transition-only: an account that has never verified receives nothing automatically, and one whose entitlement an admin revoked does not get it back by verifying again. An admin can also grant or revoke it directly, independently of verification.
- Name
- Passkeys
- Type
- pipeline toggle
- Description
- The dashboard exposes the pipeline setting as
Passkeys. New pipelines are created with passkeys on; existing pipelines keep their current setting.
| Track | Surface | Entitlement | Availability |
|---|---|---|---|
| V1 | Pipeline editor / direct REST | Not applicable | No direct V1 passkey ceremony. The shared pipeline field does not add a V1 passkey route. |
| V1 | Widget | All three gates | Supported through the widget when global rollout, account entitlement, and the linked Passkeys setting are true. |
| V1.2 | Pipeline editor / direct REST | All three gates | Supported through /api/v1.2/transactions/passkey when the pipeline is configured for V1.2 and all three gates are true. |
| V1.2 | Widget | Not applicable | Unavailable: widget attachment rejects V1.2 pipelines. |
| V2 | Pipeline editor | All three gates | The editor controls the linked Passkeys setting; the runtime passkey path is the widget. |
| V2 | Widget | All three gates | Supported through the widget when all three gates are true; widget validation exposes featureFlags.passkey_enabled. |
In practice you only touch the pipeline toggle
The first two gates are controlled by Akedly and account configuration. In the dashboard, enable Passkeys in the pipeline's Passkeys section. If that setting is on and a returning user still gets an OTP, check account access and whether the user has a passkey on that device.
The pipeline control lives on the pipeline page, described in-product as "Let returning users verify with a device passkey and brand the experience":
Once passkeys are live, enrolled credentials and passkey activity are visible
under Monitoring → Authentication → Passkeys (/admin/passkeys) in the dashboard:
Turn it on when you're ready
Everything here is fully backward-compatible. Until all three gates are
open, the widget runs OTP exactly as it does today. Add the allow attribute
now, and passkeys will light up once the current production rollout, your account entitlement,
and the pipeline setting all permit access — no further code changes needed.
Platforms & surfaces
What you change depends only on how you embed the widget. There are no new API routes, parameters, or SDK upgrades — the widget runs the passkey ceremonies itself.
Handling the result
The result contracts are identical to standard V2 — consumers should key only on type === "AUTH_SUCCESS" and tolerate extra optional fields. AUTH_SUCCESS is an optional hint: with a web Front-end Redirect URL the widget sends the user to that URL from inside the widget frame and does not post it, so treat the backend webhook, or a status lookup, as the source of truth.
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.
{
"type": "AUTH_SUCCESS",
"attemptId": "attempt_a1b2c3d4e5f6...",
"transactionId": "pkreq_9f8e7d6c5b4a...",
"redirectUrl": "https://yourapp.com/auth/callback?status=success&...",
"timestamp": "2026-01-16T12:05:30.123Z"
}
Redirect
The success redirect query string is unchanged:
…?status=success&transactionId=…&attemptId=…×tamp=…&meta_*
Webhook signing
The webhook envelope is byte-compatible with the standard V2 webhook — same shape and signing mechanism. Signing is best-effort for widget callbacks: headers are present when signing succeeds, but a callback is still sent if signing fails. For passkey verifications, note the following:
- Name
- transaction.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
- transaction.verificationMethod
- Type
- string
- Description
Optional.
"passkey"when the user verified with a passkey.
- Name
- transaction.resolvedChannel / transaction.channels
- Type
- string / string[]
- Description
Absent for passkey verifications — there is no OTP delivery channel (WhatsApp / SMS / Email) involved. Don't depend on these fields being present.
Verify when signature headers are present
Passkey webhooks may include HMAC-SHA256 svix-id, svix-timestamp, and
svix-signature headers when signing succeeds. Because the widget still sends
callbacks if signing fails, missing headers mean the result is unverifiable —
not proof of forgery. Verify the signature on the raw request bytes whenever
the headers are present, and confirm important outcomes from trusted API state.
See the webhook signing guide →
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.
Debugging tips
FAQ & gotchas
Support & resources
- V2.0 Widget SDK guide: /authentication/v2
- Webhook signing guide: /webhooks
- Dashboard: https://app.akedly.io
- Support: support@akedly.io
