Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 30 Oct 2026. What's built · Install 0.1.0
jadpo
Reference: Identity

Identity

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-rs library 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 WebAuthnCredential type, 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::

example
// 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: needs browser_session: with its origin:. 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 for https://notes.example works only there.
  • passkey: needs magic_link: in the same sign_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:'s address: 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:

text
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:

text
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:

text
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_challenges system table with what checking the answer needs: server-side, never in the cookie. The cookie, <cookie>_passkey (HttpOnly, Secure, SameSite=Strict, lasting expires_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 POST needs an Origin naming 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 are passkey_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, sends 0 every time and is never refused for it.
  • A person whose active: doesn't hold is user_disabled, as on any request.
  • On success the passkey's counter, backup state and last use are updated, a browser session starts as sessions.start starts one, and <cookie>_passkey is 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:

text
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", ... } }
text
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.
  • excludeCredentials lists the person's passkeys, so a device that holds one already says so rather than making a second.
  • name is required: the person's own name for the device, one line. A passkey registered already, to anyone, is passkey_already_registered; a 21st is passkey_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

text
GET /auth/passkey200 OK[{ "id": "0192d3c4-...", "name": "Work phone", "registered_at": "2026-10-18T09:00:00Z", "last_used_at": "2026-10-18T09:05:00Z" }]
text
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" }
text
DELETE /auth/passkey/0192d3c4-...204 No Content
  • Each reaches only the signed-in person's own passkeys: any other id is passkey_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 no generate_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 id and 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:

example
config {    browser_origin: Url {        test: "https://notes.example"    }    mail_sender: Email    smtp_host: Hostname}
example
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    }}
example
authentication {    browser_session: {        cookie: "notes_session"        origin: config.browser_origin    }    sign_in: {        magic_link: {            link: "/sign-in/confirm"        }        passkey: {            expires_after: 2m        }    }}
example
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:

example
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:

example
// 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:

example
principal {    user: {        subject: Member.email    }}authentication {    browser_session: {        cookie: "session"    }    sign_in: {        passkey: {}    }}entity Member {    id: Uuid    email: Email {        unique: true    }}
text
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:

example
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    }}
text
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.