Passkeys
On this page
A passkey lets a person sign in with their fingerprint, their face or their device's PIN, with no password and no email to wait for. The device holds a key, and the person unlocks it. The fingerprint or face never leaves the device, and your app stores only a public key, so there is no secret on the server to leak. A passkey works only on the site it was made for, so a fake site can't use it. Passkeys are the WebAuthn and FIDO2 standards.
Jadpo does all of it for you:
- Registering and signing in. The exchanges between the browser and
your server, called ceremonies, are built in: the one-time challenge,
its expiry, the check of the site's origin, and the signature counter.
Jadpo checks each as the
webauthn-rslibrary does, and its tests hold it to that library's answers. It doesn't ask the device to prove its make (attestation). - Stored with the user as the standard
WebAuthnCredentialtype, never a biometric. The personal-data report lists only each passkey's identifier and when it was last used. - Recovery is a magic link. A person who has lost every device with a passkey signs in by email and adds a new one, so passkeys need magic links in the same app.
- More than one passkey per person. Each has a name the person gives it and can be removed, so a phone and a laptop each have their own.
passkey: goes in sign_in: beside magic_link::
// authentication.jadpoauthentication { browser_session: { cookie: "notes_session" origin: config.browser_origin } sign_in: { magic_link: { address: Member.email link: "/sign-in/confirm" } passkey: {} }}
| Setting | Default | Allowed |
|---|---|---|
expires_after: |
5m, how long a ceremony's challenge lasts |
1m to 10m |
path: |
"/auth/passkey", where the generated routes go |
a path of fixed segments |
passkey:needsbrowser_session:with itsorigin:. Every answer from a device is checked against that origin. Its host is the relying-party ID, the site name a passkey is bound to. Jadpo reads it from your configuration, never from the request, and never widens it to a parent domain or another port. A passkey made forhttps://notes.exampleworks only there.passkey:needsmagic_link:in the samesign_in:. It is how a person with no passkey left gets back in.- Every passkey checks the person, not just the device
(
userVerification: "required"), so it is two factors in one: the device, and the fingerprint, face or PIN that unlocks it. - Every passkey is discoverable (
residentKey: "required"): the device can find it without being told whose it is. So signing in asks for no address first, and tells no one which addresses have an account. - The device lists a passkey under the person's address (the field
magic_link:'saddress:names) and your app's host. - A person has at most 20 passkeys.
The routes
Jadpo generates seven routes under path:, /auth/passkey unless you
change it. Anyone may call the two sign-in routes. Adding and managing
passkeys needs the person signed in with a browser session. Any other
credential, an API token among them, is 403 not_permitted, since a
passkey it added would outlive it. Every write needs the session's CSRF
token and an Origin naming your app's site, as any write with the
session cookie does:
POST /auth/passkey/sign-in/options options for signing inPOST /auth/passkey/sign-in the device's answer: starts a browser sessionPOST /auth/passkey/register/options options for adding a passkey (signed in)POST /auth/passkey/register the new passkey and its name: keeps it (signed in)GET /auth/passkey the person's passkeys (signed in)PATCH /auth/passkey/{passkey_id} renames one (signed in)DELETE /auth/passkey/{passkey_id} removes one (signed in)
Signing in
Signing in is two requests around the browser's own prompt. The first,
with no body, gives the options for navigator.credentials.get() (which
PublicKeyCredential.parseRequestOptionsFromJSON reads) and a cookie
binding them to this browser:
POST /auth/passkey/sign-in/options200 OK{ "publicKey": { "challenge": "Vh3q...", "rpId": "notes.example", "userVerification": "required", "timeout": 300000, ... }, "mediation": "conditional" }Set-Cookie: notes_session_passkey=...; HttpOnly; Secure; SameSite=Strict
The frontend posts what the device answers, as WebAuthn's toJSON()
writes it:
POST /auth/passkey/sign-in{ "credential": { "id": "AQID...", "rawId": "AQID...", "type": "public-key", "response": { "clientDataJSON": "...", "authenticatorData": "...", "signature": "...", "userHandle": "..." } } }204 No ContentSet-Cookie: notes_session=...; notes_session_csrf=...
- Each options request makes a new challenge of 32 random bytes, kept in
the
jadpo_passkey_challengessystem table with what checking the answer needs: server-side, never in the cookie. The cookie,<cookie>_passkey(HttpOnly,Secure,SameSite=Strict, lastingexpires_after:), holds 32 more random bytes kept only as a verifier, and binds the challenge to the browser that asked; a new request replaces that browser's earlier challenge. mediation: "conditional"lets the browser offer the passkey in the address field's autofill; a frontend with its own "Sign in with a passkey" button drops it.- The sign-in
POSTneeds anOriginnaming the app's site, as a magic link's confirm does. Its challenge is used up when the answer arrives, whether or not the answer checks: a replayed answer, one whose challenge expired, and one from another browser arepasskey_challenge_expired. - The answer is checked: the challenge, the origin and the
relying-party ID, that the person was present and verified, the
signature against the stored public key, and the signature counter. Any
failure is
passkey_rejected, never saying which, and so is an answer from a passkey that was removed or never registered. - The counter. A device that keeps a signature counter adds to it each
time it signs. When the stored counter or the answer's is above zero,
the answer's must be higher than the stored one (WebAuthn Level 3,
§7.2, the signature counter's step). One that isn't means the key may
have been copied, so the sign-in is refused (
passkey_rejected) and the passkey left as it was, so the person signs in another way. A passkey that keeps no counter, which most synced ones don't, sends0every time and is never refused for it. - A person whose
active:doesn't hold isuser_disabled, as on any request. - On success the passkey's counter, backup state and last use are
updated, a browser session starts as
sessions.startstarts one, and<cookie>_passkeyis cleared. A passkey signs in a browser session only. Your own mobile and command-line clients sign in with a magic link's code.
Adding a passkey
Adding a passkey is the same two steps, signed in:
POST /auth/passkey/register/options200 OK{ "publicKey": { "challenge": "o7Wd...", "rp": { "id": "notes.example", "name": "notes.example" }, "user": { "id": "...", "name": "ada@example.com", "displayName": "ada@example.com" }, "excludeCredentials": [...], "authenticatorSelection": { "residentKey": "required", "requireResidentKey": true, "userVerification": "required" }, "attestation": "none", ... } }
POST /auth/passkey/register{ "name": "Work phone", "credential": { "id": "AQID...", "rawId": "AQID...", "type": "public-key", "response": { "clientDataJSON": "...", "attestationObject": "..." } } }201 Created{ "id": "0192d3c4-...", "name": "Work phone", "registered_at": "2026-10-18T09:00:00Z", "last_used_at": null }
- The challenge is bound to the session that asked for it, used up when
the answer arrives whether or not it checks, and refused from any other
session (
passkey_challenge_expired); a new options request replaces that session's earlier challenge. excludeCredentialslists the person's passkeys, so a device that holds one already says so rather than making a second.nameis required: the person's own name for the device, one line. A passkey registered already, to anyone, ispasskey_already_registered; a 21st ispasskey_limit_reached.- The user handle the device keeps is 16 random bytes per passkey, never
the user's
id, so it says nothing about the account.
Listing, renaming and removing passkeys
GET /auth/passkey200 OK[{ "id": "0192d3c4-...", "name": "Work phone", "registered_at": "2026-10-18T09:00:00Z", "last_used_at": "2026-10-18T09:05:00Z" }]
PATCH /auth/passkey/0192d3c4-...{ "name": "Old phone" }200 OK{ "id": "0192d3c4-...", "name": "Old phone", "registered_at": "2026-10-18T09:00:00Z", "last_used_at": "2026-10-18T09:05:00Z" }
DELETE /auth/passkey/0192d3c4-...204 No Content
- Each reaches only the signed-in person's own passkeys: any other
idispasskey_not_found. Removing the last one is allowed; the person signs in with a magic link and adds another. Removing a passkey ends no session it started. - Each route's response shape is fixed
(Generated routes). The routes belong to
the first API that accepts browser sessions, and are listed in its
record, its OpenAPI document and
jadpo routes, marked generated. - Your app writes no passkey routes of its own: one at any of these paths
is
route.path_overlap. Unlike magic links, there is nogenerate_routes: false. Every body is WebAuthn's rather than your app's, so your own route would have nothing to add.
What is kept
jadpo_passkeys, a system table in the store of the principal's user
entity beside jadpo_sessions, holds each passkey as a
WebAuthnCredential from the standard library:
- its credential ID and its public key (a COSE key);
- its signature counter and its transports;
- whether it may be backed up, and whether it is;
- its name, and when it was registered and last used;
- the user's
idand the user handle.
None of it is a secret: a copy of the table signs no one in. The personal-data report lists only each passkey's credential ID and its last use.
Recovery
A person with no passkey left signs in with a magic link or its code, then adds a new passkey through the routes above. Nothing else recovers an account, so a lost phone never needs an operator.
A whole app with passkeys
The origin setting, the user, passkey: beside the browser session, and
magic links beside it, with the email connection and the app's name their
email needs:
config { browser_origin: Url { test: "https://notes.example" } mail_sender: Email smtp_host: Hostname}
use journal/entities { Member }application { display_name: "Notes" connections: { mail: { driver: Driver.smtp host: config.smtp_host } } email: { connection: mail from: config.mail_sender }}principal { user: { subject: Member.email }}
authentication { browser_session: { cookie: "notes_session" origin: config.browser_origin } sign_in: { magic_link: { link: "/sign-in/confirm" } passkey: { expires_after: 2m } }}
entity Member { id: Uuid email: Email { unique: true } policy: { create: [Access.public] read: [id] }}
A route test drives the generated routes with test_passkeys
(In development and tests). Ada adds a passkey
and signs in with it:
use journaltest "a passkey signs Ada in" { var ada: Principal.user = journal.member_called("ada@example.com") var phone: TestPasskey = test_passkeys.register(ada, "Phone") var response: Response = test_passkeys.sign_in(phone) assert response.status == 204}
The API record lists the routes Jadpo answers, as jadpo routes and
OpenAPI do. WebAuthn's own JSON goes in and out of the ceremonies, and the
record names each body for what it is:
// Generated by Jadpo from the routes of API main; update it with `jadpo api update`, never by hand.record api 1api main authentication browser_sessionPOST /auth/magic-link generated magic link authentication none input MagicLinkAsk (body) status 202POST /auth/magic-link/confirm generated magic link authentication none input MagicLinkConfirmation (body) output MagicLinkConfirmed status 200GET /auth/passkey generated passkey caller user output List<PasskeyView> status 200POST /auth/passkey/register generated passkey caller user input PasskeyRegistration (body) output PasskeyView status 201POST /auth/passkey/register/options generated passkey caller user output PasskeyCreationOptions status 200POST /auth/passkey/sign-in generated passkey authentication none input PasskeyAnswer (body) status 204POST /auth/passkey/sign-in/options generated passkey authentication none output PasskeyRequestOptions status 200PATCH /auth/passkey/{passkey_id: Uuid} generated passkey caller user input PasskeyName (body) output PasskeyView status 200DELETE /auth/passkey/{passkey_id: Uuid} generated passkey caller user status 204GET /openapi.json generated openapi status 200output MagicLinkConfirmed return_to: Textoutput PasskeyView id: Uuid last_used_at: Instant? name: Text registered_at: Instant
When the setup is incomplete
Without an origin there is nothing to bind a passkey to:
principal { user: { subject: Member.email }}authentication { browser_session: { cookie: "session" } sign_in: { passkey: {} }}entity Member { id: Uuid email: Email { unique: true }}
error: `passkey:` needs the browser session's `origin:` --> main.jadpo:12:9 |12 | passkey: {} | ^^^^^^^ no origin to bind to reason: A passkey works only on the site it was made for: every answer from a device is checked against the origin `browser_session:`'s `origin:` reads from configuration (area 10), never one the request names. next: Add `origin: config.<name>` to `browser_session:`, reading a `Url` setting (agent) rule: semantic.auth_setting_required help: https://jadpo.dev/docs/errors/semantic.auth_setting_requiredcheck failed: 1 error, 1 file
Without magic links, a person who loses every device has no way back in:
config { browser_origin: Url}principal { user: { subject: Member.email }}authentication { browser_session: { cookie: "session" origin: config.browser_origin } sign_in: { passkey: {} }}entity Member { id: Uuid email: Email { unique: true }}
error: `passkey:` needs `magic_link:` beside it --> main.jadpo:17:9 |17 | passkey: {} | ^^^^^^^ no way back in reason: A person who loses every device holding a passkey signs in with a magic link or its code and adds a new one; with no `magic_link:` in the same `sign_in:`, nothing gets them back in. next: Add `magic_link: { link: "<path>" }` to `sign_in:`, beside `passkey:` (agent) rule: semantic.auth_setting_required help: https://jadpo.dev/docs/errors/semantic.auth_setting_requiredcheck failed: 1 error, 1 file
Markdown for agents: /docs/passkeys.md.Built from Jadpo main at a707efb.