Skip to content

Migrating an existing login

Your site already has accounts: emails and password hashes in your own database. This page moves those people onto the standard door at auth.omxus.com without anyone resetting a password, and hands your old user id back to you on every sign-in so your own tables keep working.

Optional. Nothing here runs unless you run it.

What moves, what stays

Moves Stays with you
Email, and whether you had verified it Every other column and table
Password hash (replaced at first sign-in) Usernames: each person picks one the first time
Your user id, returned as legacy_id What "admin" means on your site
An admin flag, returned as roles: ["admin"]

Hashes the provider can check: pbkdf2$<iterations>$<salt base64>$<hash base64>, PBKDF2-SHA256, a 32-byte hash, salt at least 8 bytes, 100,000 iterations or fewer (Workers caps WebCrypto PBKDF2 there). Anyone with another format, bcrypt or a higher iteration count is still imported, without a password: they sign in with a code sent to their email, then set one.

1. Register on the standard door

Register a site with "door": "standard". For a browser app:

curl -s https://auth.omxus.com/oidc/register \
  -H 'content-type: application/json' \
  -d '{
    "client_name": "Your Site",
    "door": "standard",
    "username_scope": "site",
    "redirect_uris": ["https://yoursite.example/auth/callback"],
    "token_endpoint_auth_method": "none"
  }'

Untested against production (each call makes a real client). Keep the client_id.

2. Export your users

One row per person. Columns read, the rest ignored:

Column Required Meaning
id yes Your user id. Becomes legacy_id. Up to 128 characters.
email yes Lowercased and trimmed on import.
password_hash no The format above.
email_verified no 1, true, 0, false. Default false.
is_admin no 1 or true gives role admin for your client.
created_at no YYYY-MM-DD HH:MM:SS UTC, or epoch seconds.

From Cloudflare D1:

wrangler d1 execute YOUR_DB --remote --json \
  --command "SELECT id, email, password_hash, is_admin, email_verified, created_at FROM users" > users.json

The importer reads that output as it is, a plain JSON array, or one JSON object per line. The file holds password hashes: keep it off shared drives and delete it when you are done.

3. Turn it into SQL

node server/accounts/import/import-users.mjs \
  --client-id YOUR_CLIENT_ID --in users.json --out import.sql

The importer lives in the auth repository, which is private today. Ask the operator for it, or have them run this step with you.

It prints a report and never prints an address or a hash. A run over five sample rows:

rows 5, imported 3 (1 with a password, 1 verified, 1 admin)
  row 2: duplicate email (first row kept)
  row 3: password hash is not in the PBKDF2 format; imported without a password
  row 4: password hash has 600000 iterations (max 100000); imported without a password
  row 5: no usable id

Rows dropped outright: no usable id, an invalid email, or an email already seen earlier in the file. The SQL is idempotent: running it twice adds nothing twice. Account ids come from your client id and old id, so the same row always lands on the same account.

4. Load it

The provider's database belongs to its operator. On auth.omxus.com, send import.sql to the operator. On your own provider, run it yourself:

wrangler d1 execute <your-db> --remote --file=server/accounts/legacy-import.sql    # once
wrangler d1 execute <your-db> --remote --file=server/accounts/accounts-claims.sql  # once
wrangler d1 execute <your-db> --remote --file=import.sql

Who a row lands on

Only a verified email decides which account a row belongs to. A bare match on the address never does.

  • Someone already verified that email on the standard door, on any site: your legacy_id and role attach to their account. Their current password stays; your old hash is not stored for them.
  • Nobody has, and your row says email_verified = 1: the import makes its own account with that email verified. Any sign-up still waiting on a code for that address is dropped.
  • Nobody has, and your row says email_verified = 0: the import makes an account holding only a claim on the address. Nobody, you included, sees its legacy_id or roles, and the old password cannot be used, until the person proves the address: a code sign-in, or a fresh sign-up that verifies.
  • A sign-up that typed the email but never entered its code gets nothing.

Two sites importing the same unverified email each make their own claim. Whoever verifies the address first becomes the one account; every waiting import's legacy_id and role move to it, and its old hash too if the winner has no password. Each site still sees only its own legacy_id and roles.

Imported accounts are exempt from the 30-day clean-up that removes idle unverified sign-ups. They stay until verified or removed (see Undo).

What your users see

They sign in with their old email and password, then pick a username. That is all they notice.

Underneath, two calls to POST /accounts/login:

  1. The SDK sends the usual stretched password. There is no current hash to match, so the provider answers 401 legacy_password_required with a one-time legacy_ticket (valid 300 seconds).
  2. The SDK sends the plain password once over HTTPS, the stretched one as new_password, and the ticket. The provider checks the old hash in constant time, stores the new one, deletes the old one in the same step, and signs the person in.

Every later sign-in is step 1 only. The hosted sign-in page and omxus-accounts.mjs both do this for you (login() retries on legacy_password_required). If you call the HTTP routes yourself, handle that error the same way.

Once a client has imported anyone, it answers every failed password sign-in with legacy_password_required, real addresses and made-up ones alike, so the answer never says who is in your import. A wrong password at step 2 is a plain 401 bad_login.

What your site gets

On every sign-in, for your client only, and only once the account holds a verified contact:

  • legacy_id: your old user id. Look up your own rows with it.
  • roles: ["admin"] for rows that had is_admin.

They arrive in the id_token and userinfo over OIDC, and on account.legacy_id and account.roles from omxus-accounts.mjs. No other site ever sees them.

const account = await accounts.login({ email, password });
const user = account.legacy_id
  ? await db.users.get(account.legacy_id)   // your old row
  : await db.users.createFor(account.id);   // someone new
const isAdmin = (account.roles || []).includes('admin');

Untested sketch: accounts is the client from Your own sign-in page; db stands for your own data layer.

Changing email or mobile later

Same rule as every account: changing a verified contact needs the current password when the account has one. An imported person who has not signed in yet has no current password (only the old hash), and one with no password changes contact with a code alone. See Sessions, activity and the account.

Undo

Before anyone has signed in, delete the imported accounts by id. Each id is the first 32 hex characters of sha256("omxus-import:v1|<client_id>|<old id>"). On auth.omxus.com, ask the operator to use /accounts/operator/delete per account. Running the import again brings back an account you removed.