Passkeys¶
People sign in to Omxus with Face ID, Touch ID or Windows Hello. Nothing typed. Omxus has two kinds of passkey, and every passkey belongs to one hostname. Know both facts and passkey sign-in never surprises you.
Two kinds¶
| Kind | Made where | What the server keeps | How it signs in |
|---|---|---|---|
| Account passkey | "Save a passkey" on the standard door (/accounts/passkey/register/*) |
One row: credential id, public key, RP id | A WebAuthn assertion, checked against the stored public key |
| Identity passkey | The full Omxus door (omxus-credential.mjs) |
Nothing. The identity is the passkey's PRF output (salt omxus-seed:v1) |
The browser derives a per-site key from that seed and signs a server nonce (/accounts/omxus/challenge, then /accounts/omxus/finish) |
An account passkey is a login method on an account. An identity passkey is the identity: the same passkey gives the same address every time, and a different site gets a different address, so sites cannot compare notes.
"Use a passkey" on the standard door asks for both at once. The request
carries the PRF extension, so an identity passkey hands over its seed and an
account passkey is matched by its row (passkeyLogin in
omxus-accounts.mjs).
If you use OIDC, you do nothing¶
Sites that sign people in through Any app, via OIDC send people
to auth.omxus.com. Passkeys
happen there. Your site receives the same ID token either way.
One hostname each¶
WebAuthn ties a passkey to its RP id. Omxus uses the hostname of the page that asked:
- On the hosted door, that is
auth.omxus.com. - If you build your own sign-in page on the accounts API, the RP id is
your page's hostname. A passkey saved on
app.example.comis never offered onwww.example.com.
The server refuses a passkey whose stored RP id differs from the calling
page's host (401 passkey_unknown).
The accounts API calls¶
All are POST. Challenges last 300 seconds; user verification is required.
| Call | Body | Answer |
|---|---|---|
/accounts/passkey/register/options |
(signed in) | WebAuthn creation options |
/accounts/passkey/register/finish |
{credential} |
Saved, or 400 passkey_failed |
/accounts/passkey/login/options |
none | {challenge, rpId, userVerification, timeout} |
/accounts/passkey/login/finish |
{credential} |
A session, or 401 passkey_failed / passkey_unknown |
/accounts/omxus/challenge |
{address} |
{nonce, issuedAt, ttlSeconds} |
/accounts/omxus/finish |
{signed, root, create?} |
A session, or 409 omxus_unknown with a ticket |
/accounts/omxus/create |
{ticket} |
A new account holding that key |
/accounts/omxus/link |
{ticket} (signed in) |
That key added to the signed-in account |
A key no account knows yet¶
When an identity passkey's key matches no account, /accounts/omxus/finish
answers 409 omxus_unknown with a ticket that lasts ten minutes. Show one
step with two choices:
- Create a new account:
POST /accounts/omxus/createwith the ticket. - Sign in to link it: sign in with password or code, then
POST /accounts/omxus/linkwith the ticket.
Send create: true to /accounts/omxus/finish to skip the question and
create the account at once. The SDK does both: hasPendingKey() tells you a
ticket is waiting.
A passkey the server never saved¶
If a save never finished, the device holds a passkey the server has no row
for. The server refuses it, and the SDK tells the device to drop it with
PublicKeyCredential.signalUnknownCredential where the browser supports it.
The person stops seeing a passkey that cannot work.
Limits today¶
- One identity address per account. A second device's identity passkey has a different seed, so it needs its own link step.
- An account passkey works only on the host it was saved on.
Checked¶
Routes, TTLs and error codes checked against server/accounts/accounts-routes.js
on 30 Oct 2026. The passkey suite (node sdk/passkey.test.mjs in the auth
repo) passes 58 of 58. The tables above are not a runnable sample; the SDK
calls them for you.