Skip to content

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) and omxus-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 device tier 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:

  1. 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.
  2. Nothing found or the prompt cancelled: make one (this call is the press).
  3. Passkey without PRF, but a sealed vault exists: unseal it and sign in.
  4. 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.
  • device or 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.