Skip to content

How identity is derived

An Omxus identity is a key, not a row in a table. The same inputs make the same key on any device, so nothing about the person has to be stored to sign them in. Sites never see that key. Each site gets its own key, and each signed-in app gets its own sub.

This page follows one person from what they type to the sub your app receives.

three inputs ─┐
phrase ───────┼─► seed ─┬─► master key  m/44'/60'/0'/0/0
passkey ──────┘         └─► site key    m/44'/60'/1'/a'/b'/c'
                                  │
                                  ▼
                 sub = pairwise(site address, sector)

1. Making a seed

There are three ways to a seed. They end in the same place, and a site cannot tell which one was used.

Way in What becomes the seed
Three inputs
deriveIdentity(name, dob, words)
name + " " + dob + " " + words, run through BIP-39's PBKDF2 (SHA-512, 2,048 rounds, salt "mnemonic"), 64 bytes
Recovery phrase
importMnemonic(phrase)
A 12- or 24-word BIP-39 phrase from generateMnemonic(), checked by validateMnemonic(), same PBKDF2
Passkey
identityFromSeed(seed)
The authenticator's PRF output, used as the seed directly (no PBKDF2). At least 16 bytes

The three inputs are cleaned first. Name and words are trimmed, lowercased and have runs of spaces collapsed. The date is only trimmed, so its format matters: the sign-in pages use a date field, which sends YYYY-MM-DD. Two people typing Ada Lovelace and ada lovelace get the same key; 1815-12-10 and 10/12/1815 do not.

Only the words are secret. Name and date of birth are public, so they act as a salt. The words must score at least 60 bits before any key is made (validateWords()); Security model explains the scoring and its limits. Because the public key travels with every sign-in, a weak secret can be guessed offline. Offer passkeys or a recovery phrase to anyone who will take one.

2. The master key

From the seed, standard BIP-32 derivation along m/44'/60'/0'/0/0 gives the master key: an ordinary Ethereum key and address. It is what signs on-chain actions, such as vouching. It is never used to sign in to a site.

3. A key for each site

Every website gets its own key from the same seed, on a sibling branch:

m/44'/60'/1'/a'/b'/c'

a, b and c come from keccak256("omxus-site:v1:" + origin): the first 93 bits, split into three 31-bit indices. Every step is hardened, so leaking one site's key cannot be walked back to the seed or across to another site.

The origin is canonicalised first, so client and server hash the same string: lowercase scheme and host, default port dropped, path dropped, https:// assumed when no scheme is given.

Input Canonical origin
https://auth.omxus.com https://auth.omxus.com
HTTPS://Auth.Omxus.com:443/path https://auth.omxus.com
auth.omxus.com https://auth.omxus.com
http://localhost:8080 http://localhost:8080

Two consequences:

  • Sites cannot link people. Two sites see two unrelated addresses. Joining them needs the seed.
  • Signatures do not travel. The signed challenge names the origin (see Security model), so a signature made for one site is worthless at another.

Nothing is stored for this. The site key is a pure function of seed and origin, so every device holding the seed gets the same answer.

4. What Sign in with Omxus hands your app

When a person signs in through auth.omxus.com, the door derives the site key for https://auth.omxus.com. That address is the one the provider knows. Your app then receives a pairwise sub:

sub = first 40 hex of sha256("omxus-pairwise:v1:" + sector + ":" + address + ":" + salt)
  • address is the auth.omxus.com site address, lowercased.
  • salt is a provider secret. Without it, anyone holding an address and a hostname could recompute the sub and match people across sites.
  • sector is your client's approved sector_identifier_uri, or else the host of its first redirect URI, or else its client_id.

So the same person has a different sub at every site, and the same sub every time at yours. Clients sharing a sector share a sub: that is how a website and its iOS app see one person (Registering a site).

Standard-door accounts (email or phone and password, Your own sign-in page) have no key. Their sub uses the same formula with "account:" + account_id in place of the address, so it can never equal a key-derived sub.

omxus_address. With the profile scope, the id_token for a key-derived identity also carries omxus_address, the auth.omxus.com site address. It is the same at every site that asks for it. Key accounts on sub, never on omxus_address.

5. Email is a handle, never a key

A key made from an email address could be made by anyone who knows the address. So email is only ever a lookup handle:

emailHandle(email) = keccak256("omxus-handle:v1:" + email.trim().toLowerCase())

It hides the address from someone skimming a database dump. A list of known addresses breaks it, so it is not a secret and never proves who someone is.

Try it

These calls run the engine's own code (checked 30 Oct 2026 with Node 22 against engine.bundle.mjs):

import '/path/to/engine.bundle.mjs';   // sets the functions on globalThis

const me   = await deriveIdentity('Ada Lovelace', '1815-12-10', 'your secret words');
const site = await deriveSiteIdentity(me.mnemonic, 'https://example.com');

me.address    // master address, m/44'/60'/0'/0/0
site.address  // this site only; another origin gives an unrelated address
canonicalOrigin('HTTPS://Example.com:443/x')   // 'https://example.com'

Not built yet

  • Online guessing. The three inputs can be guessed offline today. The planned fix makes the provider take part in every derivation (an OPRF), so each guess goes through a rate limit and a stolen database cannot be ground. It would mean a new device needs the network the first time.
  • More rounds. PBKDF2 stays at the BIP-39 count of 2,048. Raising it needs a migration for existing keys.

Until then, prefer passkeys and recovery phrases for anything that matters.