Remembering people¶
A site that asks for the same three things on every visit has no memory, and people leave rather than type them again. Omxus remembers people on their own device, so the second visit is one press: no phrase, no form.
Not reachable from outside yet
The code on this page is omxus-credential.mjs. It is not served at
auth.omxus.com/sdk/ (404, checked 30 Oct 2026). Today, sign people in
with OIDC or Signed in everywhere.
The rule¶
The recovery phrase is typed once, at setup, and never again. A page that asks for twelve words at every sign-in trains people to type their seed into whatever asks. That habit is what phishing feeds on.
Four tiers, best first¶
The SDK picks the best one the device offers, at run time.
| Tier | Stored as | What the person does | Leaves the device? |
|---|---|---|---|
passkey-seed |
Nothing secret. The passkey's PRF output is the seed. | Face ID or Touch ID | Where the passkey syncs (iCloud Keychain, Google Password Manager) |
passkey |
Phrase sealed with AES-GCM; key = passkey PRF output | Face ID or Touch ID | No |
device |
Phrase sealed with a non-extractable AES-GCM key in IndexedDB | Nothing | No |
| (none) | Nothing | Types the phrase, or name, date of birth and words | Recovery and new devices |
- PRF is the WebAuthn extension that makes a passkey return 32 bytes,
the same bytes every time for the same credential and salt. Omxus uses two
salts:
omxus-seed:v1(the output is the identity) andomxus-auth-seed-wrap-v1(the output wraps a phrase). One passkey at two salts gives two unrelated secrets, so a wrapped vault can never be read as an identity, or the reverse. - The
devicetier has no biometric prompt. Its key can be used by script but not read out, so a copy of the browser profile is not a copy of the phrase. - Every passkey made here is resident (
residentKey: 'required') and needs user verification, so a stolen unlocked phone is not a stolen account.
What is stored¶
One localStorage entry, omxus.vault.v1:
{ "wrapped": "<base64>", "iv": "<base64>", "tier": "passkey",
"credentialId": "<base64>", "address": "0x..." }
For passkey-seed it holds only tier, credentialId and address: there is
nothing to unwrap, because the passkey is the identity. The tier 2 key lives in
IndexedDB database omxus-auth, store keys, entry wrapKey.
What crosses the wire¶
In every tier the seed stays in the page. The SDK derives this origin's key,
asks the provider for a nonce (POST /auth/challenge with {address}), signs
it with the origin inside the signed bytes, and sends the signature
(POST /auth/verify). The provider cannot tell which tier signed. See
Security model.
One press¶
const cred = createCredentialStore({ issuer: 'https://auth.omxus.com' });
const session = await cred.signIn();
// session.tier: 'passkey-seed', 'passkey' or 'device'
signIn() does this, in order:
- Ask for the passkey the vault names, or, with no vault, for any passkey this origin has: the one in the password manager, on the phone, or on this device after storage was cleared. It never makes a second passkey for a person who already has one.
- Nothing found or the prompt cancelled: make one (this call is the press).
- Passkey without PRF, but a sealed vault exists: unseal it and sign in.
- No PRF and no vault: generate a random phrase, sign in with it, seal it. The person never sees it, and it lives on this device alone. Tell them so.
Every biometric prompt races a timeout (passkeyTimeoutMs, 60 s by default).
An unanswered prompt never settles in the browser; without the race the page
would wait for ever.
The other calls¶
| Call | Does |
|---|---|
isEnrolled(), address(), tier() |
Read the vault. No prompt. |
enroll(phrase) |
Seal a phrase at the best tier. Checks the BIP-39 checksum first: one mistyped word derives a different, empty account. |
unlockAndSignIn() |
Biometric prompt, then a session. For a site's own returning-visitor button. |
signInWithPasskey() |
Tier 0 only. Returns 'none' when there is no passkey, null when the passkey has no PRF. Never creates. |
createPasskeyAndSignIn() |
Make a passkey and sign in with it. Only behind a "Create a passkey" press. |
signInWithPhrase(phrase) |
Recovery or a new device. Seals as it goes, so it is the last time. |
signInWithIdentity({name, dob, words}) |
The three-box screen. Seals what was typed. |
addRecovery({name, dob, words}) |
Attach recovery to a passkey identity. The address changes; returns previousAddress. Offer it right after the first sign-in, before anything hangs off the old address. |
revealPhrase() |
The sealed phrase, behind the same biometric. Throws for passkey-seed: there is no phrase. |
forget() |
Remove the vault from this device. |
What to tell people¶
passkey-seed: the identity travels where the passkey syncs, and nowhere else. On a device whose passkeys do not sync, it is device-only until they add recovery.deviceor the generated phrase: this identity lives on this browser. Offer recovery.- Three boxes: name and date of birth are public, so the account rests on the words. Sealing after the first sign-in means they type them once per device, not every visit.
Checked against sdk/omxus-credential.mjs in the auth repo; its tests
(sdk/passkey.test.mjs) pass 58/58 (30 Oct 2026). The sample is untested
outside the auth repo.