Skip to content

One database per client

Every site that signs Omxus identities in gets its own small database: a Durable Object with SQLite, named by its client_id. Registering a site adds no binding, runs no migration and needs no deploy. The database appears the first time someone signs into that site.

What is shared, and why it must be

Three things stay in the one shared D1 database for everyone.

The trust graph. identities and vouches are global. A vouch made on one site counts on every other site. Split it and each site holds its own private opinion of who is real, which is what Omxus exists to replace.

The session. auth_sessions, auth_nonces and auth_attempts belong to the provider. Landing on a second site already signed in is single sign-on; per-site sessions would be the opposite.

The registry. oidc_clients says who the clients are, so it cannot live inside any one of them.

What each client owns

The authorization codes and access tokens minted for it. No other client can read them.

Table Where it lives
identities, vouches shared D1
auth_sessions, auth_nonces, auth_attempts shared D1
oidc_clients shared D1
oidc_codes, oidc_access_tokens that client's database
client_data that client's database

client_data is a key and JSON value table. The provider can write to it, but no route reads or writes it yet, so a site cannot use it today.

Standard-door sites are different. A site whose people sign in with email or phone and a password (the standard door) keeps its codes, access tokens and refresh tokens in the shared accounts tables, not in a per-client database. Its tokens still carry the client prefix below, and the provider checks it on revoke.

How a token finds its own database

A bearer token arriving at /oidc/userinfo is otherwise opaque random hex. It says nothing about who minted it, so there would be no database to open.

Codes and tokens therefore carry the client that issued them:

<client_id>~<64 hex characters>

The part before the first ~ names the database. It works like sk_live_…: a secret that knows where it came from.

Nothing trusts the prefix. The random half (32 random bytes) is the whole secret. A forged prefix opens a different client's database, finds no such row, and the token is refused, which is what an unknown token gets anyway. A valid token with another client's prefix is worthless.

A token with no ~ was minted before the split. It falls back to the shared D1, where it was written; access tokens last an hour, so these are gone.

Lifetimes

Secret Lifetime When the row goes
Authorization code 60 seconds Deleted when spent. An unspent code stays as a dead row.
Access token 1 hour Deleted the next time it is presented after expiry.
Revoked access token until expiry Marked revoked, not deleted, so a replay still reads as revoked.

The hourly clean-up job prunes the shared D1 only. Per-client databases are cleaned on read, as above, so each one holds kilobytes.

What it costs

Durable Objects are billed per Cloudflare account, across every client. Each Omxus sign-in makes three calls to the client's database (store the code, spend it, store the token), plus one for each /oidc/userinfo call. Workers Paid includes, each month:

Included
Requests 1 million
Duration 400,000 GB-s
SQLite rows read 25 billion
SQLite rows written 50 million
SQLite storage 5 GB-month

So about 250,000 sign-ins a month (with a userinfo call each) fit in the included requests; past that, Cloudflare bills per million. Current figures: Durable Objects pricing.

Adding a client

Register at auth.omxus.com/developers or with POST /oidc/register: see Registering a site. The database is created on first use.

On your own provider an operator can also add a row to oidc_clients by hand. Registered client_ids are base64url, which never contains ~. A hand-picked id with a ~ still works: it is written as %7E inside the prefix and decoded back.

Running without Durable Objects

The worker uses per-client databases when the CLIENT_DB Durable Object namespace is bound (wrangler.toml binds it, class ClientDB). Without the binding, codes and tokens go to the shared D1, and everything else works the same.

Checked against server/client-db/, worker/client-db.js, server/oidc.js and server/accounts/accounts-oidc.js; the routing tests (per-client.test.mjs, 8 of 8) cover the cross-client refusal.