Skip to content

Multi-tenancy

Multi-tenancy here is not a column in a table. A tenant is addressed by host, and every tenant carries its own cryptographic identity.

https://acme.oauth.work/.well-known/openid-configuration # Acme's issuer
https://globex.oauth.work/.well-known/jwks.json # a different key entirely
https://oauth.work/.well-known/openid-configuration # the platform's own
  • Its own Ed25519 signing key, generated on first use and envelope-encrypted at rest.
  • Its own OIDC discovery document and JWKS, served from its own host.
  • Its own did:web:<slug>.oauth.work issuer for verifiable credentials.
  • Its own audit trail, webhooks, log streams, roles, and members.

A flow run on acme.oauth.work issues tokens with iss: https://acme.oauth.work, signed by Acme’s key. Each tenant is a self-contained OpenID Provider, not a view over a shared one.

The tenant is derived from the request’s Host header — not a path segment, not a query parameter, not a claim in a token. acme.oauth.work resolves to Acme; the apex resolves to the platform.

Discovery, JWKS, and DID documents therefore differ by host, and code that verifies tokens must follow iss rather than a hardcoded URL:

// Right: resolve the key set from the token's own issuer
const { iss } = decodeJwt(token)
const jwks = createRemoteJWKSet(new URL(`${iss}/.well-known/jwks.json`))
await jwtVerify(token, jwks, { issuer: iss, audience: clientId })

Reserved subdomains — www, api, admin, console, auth, docs, status, app, and others the platform serves itself — cannot be claimed as tenant slugs. That is a security boundary, not tidiness: a tenant on docs.oauth.work could serve a branded login page on a hostname users have reason to trust.

POST /v1/organizations
Authorization: Bearer sk_live_…
{ "name": "Acme Corp", "slug": "acme", "domain": "acme.com" }

Only a platform-scoped key may create organizations. An org-bound key gets 403 forbidden with platform-admin key required — creating a tenant is not something one tenant’s key can do.

For self-serve signup — email code, org, admin user, and first API key in one flow — use the CLI as shown in the quickstart.

Read and update an org with GET /v1/organizations/:id and PATCH /v1/organizations/:orgId. Branding fields on the org (display name, logo, primary colour) drive the hosted sign-in screens, so a customer’s users see their own name at login rather than yours.

A domain proves that an organization controls an email namespace. A verified domain is what lets /authorize route a user to their own employer’s SSO connection: when the request carries a login_hint with an email address and no org is otherwise bound, the domain resolves the org, and that org’s active connection brokers the login.

Add the domain:

POST /v1/organizations/org_01J…/domains
Authorization: Bearer sk_live_…
{ "domain": "acme.com" }
{
"domain": "acme.com",
"status": "pending",
"txt_record": {
"name": "_oauth-work-challenge.acme.com",
"type": "TXT",
"value": "oauth-work-verification=8f3b2c…"
}
}

Publish that TXT record, then verify:

POST /v1/organizations/org_01J…/domains/acme.com/verify
{ "domain": "acme.com", "status": "verified" }

A failure returns 422 verification_failed with the reason — usually the record has not propagated yet. A domain may be verified by at most one organization; a second org attempting the same domain is refused even with a valid record, so an email namespace cannot be claimed twice.

Re-adding a domain resets its challenge to a fresh token and returns it to pending.

Your customer’s IT admin can do all of this themselves through an Admin Portal link with intent: "domains" — which is usually better, since they are the ones with access to the DNS zone.

  • Cryptographic — a tenant’s tokens and credentials are signed by that tenant’s key. Verifying Acme’s token against Globex’s JWKS fails, regardless of any application-level mistake.
  • Query-level — every org-scoped route checks the caller’s org binding alongside its permission, in one guard. An org-bound API key addressing another org gets 403 forbidden; a resource id from another org comes back 404.
  • Operational — audit rows, webhook endpoints, log streams, and connected accounts are all org-scoped, and org-scoped keys are clamped to their own org even when they ask for another.
GET /v1/organizations/org_01J…/export
Authorization: Bearer sk_live_…

Tenant signing keys stay platform-managed and are not exported. Everything the standards make portable — OIDC, SAML, SCIM, VC-JWT — remains portable by design: a credential you issued verifies against a published DID document whether or not you are still a customer.