{
  "slug": "iam-broker",
  "title": "How to build an IAM broker",
  "summary": "A single front door for identity: one standalone service every app delegates auth to via OIDC / OAuth 2.1 + PKCE — with identity ≠ email, a 2-factor baseline, rotating refresh tokens, a risk engine, and thin, verifiable tokens.",
  "essential": "Auth written per-app is auth written wrong N times. A broker centralizes MFA, revocation, risk and tenant isolation in one place you can't bolt on later — so it is day-0 infrastructure.",
  "status": "pilot",
  "updated": "2026-08-21",
  "tags": [
    "iam",
    "identity",
    "oauth",
    "oidc",
    "security",
    "auth",
    "broker"
  ],
  "reference": "https://auth.schematize.org/.well-known/openid-configuration",
  "url": "https://blueprint.schematize.org/iam-broker/",
  "markdown_url": "https://blueprint.schematize.org/iam-broker.md",
  "markdown": "An IAM broker is the one service that answers **\"who is this?\"** for every other system you run. Build it once, build it as its own thing, and every app — web, mobile, API, internal tool — stops reinventing login and starts trusting a signed answer instead. This blueprint is the house recipe: what it is, the floors you don't cross, and the steps to stand one up.\n\n## What an IAM broker is\n\nAn IAM broker — identity broker, identity provider (IdP) — is a **standalone service that owns authentication** for your whole platform. It speaks **OpenID Connect (OIDC)** on top of **OAuth 2.1**. An app redirects the user to the broker; the broker authenticates them (password, OTP, passkey…) and hands back a **signed token** the app can verify on its own. The app never sees the password and never stores credentials.\n\nOne front door. Identity, factors, sessions and risk live in exactly one place; everything else is a *relying party*.\n\n## Why it's essential\n\n- **Auth per-app is auth wrong N times.** Every app that rolls its own login is a new attack surface, a new inconsistency, and a place with no central revocation. One broker means one hardened surface.\n- **It centralizes what has to be consistent:** MFA, session revocation, risk scoring, audit, passwordless rollout, tenant isolation. Change it once; every app benefits.\n- **You can't bolt it on later.** Retrofitting a shared identity into apps that each own their users is a rewrite. So the broker is **day-0 infrastructure**, not a phase-2 nicety.\n\n## The non-negotiable floors\n\nThese are the house rules. Skipping one isn't \"moving fast\" — it's shipping a breach.\n\n- **Identity ≠ email.** The stable identifier (`sub`) is an opaque ID (**ULID / UUIDv7**), never the email or phone. People change emails; identity does not. Emails are *verifiable identifiers* attached to an identity, not the identity itself.\n- **Auth is a separate app, from day 0.** The broker is `auth.<domain>` — its own service, its own deploy, its own database. Never a module inside the monolith.\n- **A 2-factor baseline — but never a wall before login.** Password **+ Email OTP** already counts as MFA. The \"infinite circle\" (you must verify to log in, but must log in to verify) is banned. Step-up happens *after* a first factor, never as a pre-login gate.\n- **The server decides authorization.** The token is **thin** — who they are and how strongly they proved it — nothing more. Every resource server enforces its own permissions. `if (user.isAdmin)` in the client is UX, not a control.\n- **No oracle.** A wrong password and a nonexistent account must be indistinguishable — same response, same timing, byte for byte. Login must never leak whether an account exists.\n- **Deny-by-default, everywhere.** Unknown client, unlisted `redirect_uri`, missing PKCE, empty input → rejected. Allow is the exception you write down.\n- **Asymmetric tokens only.** Sign with **RS256 / EdDSA**, publish a **JWKS**, and *reject* `HS*` and `none` on validation. Symmetric or unsigned tokens invite key-confusion attacks.\n- **Passwordless-first.** **Passkeys** are the target, not an add-on. Passwords are the fallback, and even then: **argon2id** + a breach check (HIBP k-anonymity) so known-compromised passwords never get set.\n\n## Architecture\n\nThe broker is a service with a handful of internal modules and two kinds of consumers.\n\n**Inside the broker**\n\n- `identity` — the opaque `sub` and its lifecycle.\n- `verifiable identifiers` — email / phone, added, verified and rotated independently of `sub`.\n- `factors` — password (argon2id + HIBP), Email OTP, TOTP (RFC 6238), passkey (WebAuthn).\n- `risk` — adaptive scoring, step-up, deceptive denial, honeypots.\n- `session` — multi-device sessions and irreversible logout.\n- `token` / `keys` / `jwks` — issuance, the signing key, and the public JWKS.\n- `providers` — email / SMS / push senders (a **sink by default** outside production, so no test ever emails a real person).\n\n**Outside the broker**\n\n- **Clients (relying parties)** are OIDC clients using **Authorization Code + PKCE**. They redirect to the broker and get a token back.\n- **Resource servers (APIs)** validate that token against the broker's **JWKS** — they *never* call the broker per request.\n\n**Discovery** ties it together: publish `/.well-known/openid-configuration` and `/.well-known/jwks.json`, and any standard client can integrate without bespoke code.\n\n```\n  app (client)                 broker (auth.<domain>)            api (resource server)\n      |  1. redirect /authorize (PKCE) →   |                              |\n      |  ← 2. code (redirect_uri)          |                              |\n      |  3. POST /token (code+verifier) →  |                              |\n      |  ← 4. id_token + access + refresh  |                              |\n      |                                    |   5. GET /jwks (cached) ←    |\n      |  6. call API with access token ───────────────────────────────→  |\n      |                                    |   7. verify JWT via JWKS     |\n```\n\n## Build it: step by step\n\n### 1. Stand it up as its own service\n\nOwn repo, own Linux user, own deploy at `auth.<domain>`. In the house that's **Rust + axum + Postgres**. **Generate the signing key on the server** — never in the repo, never in an env var committed anywhere — and mount it read-only (mode `600`). The broker boots, exposes discovery + JWKS, and nothing else can sign tokens.\n\n### 2. Model identity (≠ email)\n\n`sub` is a **ULID**, immutable for life. Email and phone are separate, verifiable rows you can add, verify, rotate or remove without touching `sub`. Passwords are hashed with **argon2id** and checked against **HIBP with k-anonymity** at set-time — a known-breached password is refused before it's ever stored.\n\n### 3. Factors — 2FA baseline, no pre-login wall\n\n**Email OTP is always-on**: password + OTP is the baseline, so an account is 2FA from the first day without a passkey. Add **TOTP (RFC 6238)** and **passkeys (WebAuthn)** as stronger factors. Enforce the **Y ≠ X invariant**: the factor you're changing can't be the one that authorizes its own change. And make **recovery at least as strong as login** — a weak reset flow is a strong backdoor.\n\n### 4. Tokens — sign asymmetric, rotate refresh\n\nIssue **JWT RS256** and publish the public key at `/.well-known/jwks.json`. `/token` supports **authorization_code + PKCE (S256)** and **refresh_token**. **Rotate the refresh token on every use**; if an old refresh is presented again, treat it as theft and **revoke the whole family**. Access tokens are short-lived and carry `sub` + `aal` (assurance level) — **not roles**.\n\n### 5. Sessions & a logout that means it\n\nTrack sessions per device. **Logout is irreversible**: put the token's `jti` on a denylist and revoke the refresh family, so \"logged out but the token still works for 15 minutes\" can't happen. Revoking one device never silently logs out the others.\n\n### 6. A risk engine, not a binary\n\nScore each attempt (new device, impossible travel, failure streaks…). When risk is high, **step up** (2FA → 3FA) instead of guessing. Deny deceptively with **constant timing** (a tarpit) so an attacker can't measure whether they got closer, and seed **honeypot** fields. The rule that ties it together: the time to *burn* a failed attempt must equal the time of a real verify — **no timing oracle**.\n\n### 7. Authorization is downstream\n\nThe broker **authenticates**; it does not decide app permissions. Hand out thin tokens and let each **resource server run its own policy** — **ReBAC**, multi-tenant, deny-by-default. Want it centralized? Use one **PDP** (policy decision point). It's still not the broker.\n\n### 8. Wire the clients (relying parties)\n\nAn app redirects to `/authorize` with **PKCE**, exchanges the `code` at `/token`, and validates the JWT via **JWKS**. The `redirect_uri` is an **exact-match allowlist** (anti open-redirect — never prefix or substring). **Public clients (SPA, mobile) carry no client secret.** Store the resulting session in an **HttpOnly, Secure, SameSite cookie** — never `localStorage`.\n\n### 9. Operate it\n\nSigning key in a vault or a `600` file, **rotation documented** (publish both keys in JWKS during the overlap so live tokens keep validating). Deny-by-default firewall, TLS everywhere, and **log every auth event** so the attack actually shows up in your telemetry. Deploy is **gated**.\n\n## Integrating a client — the short version\n\n1. Register the client: a `client_id` and an **exact** `redirect_uri` allowlist.\n2. On login, redirect to `/.well-known/openid-configuration` → `authorization_endpoint` with `response_type=code`, `code_challenge` (S256), `state`, `scope=openid`.\n3. On the callback, POST to the `token_endpoint` with the `code` + `code_verifier` (+ `client_secret` only for confidential clients).\n4. Validate the `id_token` / access token against the `jwks_uri` — check signature (asymmetric only), `iss`, `aud`, `exp`, `nonce`.\n5. Store the session server-side behind an HttpOnly cookie. For APIs, verify the access token per request against the cached JWKS.\n\n## Anti-patterns (rejected on purpose)\n\n- **Email as the primary key.** Breaks the day someone changes it; leaks identity into every foreign key.\n- **Per-app login / copy-pasted auth.** N inconsistent surfaces and no central revocation.\n- **A verification wall before the first login** — the infinite circle.\n- **Token in `localStorage`.** One XSS and it's exfiltrated. HttpOnly cookie, always.\n- **Accepting `HS256` / `none` on validation.** Key-confusion in a box. Asymmetric only.\n- **An LLM (or the client) deciding authorization.** Enforcement is deterministic and server-side; a model's opinion is not a permission.\n- **Distinguishable \"wrong password\" vs \"unknown user.\"** That's an account-enumeration oracle — same answer, same timing.\n\n## Reference implementation\n\nThe house runs this design as **`schematize_auth_rs`** (Rust / axum + Postgres), live at **`auth.schematize.org`**. Its OIDC discovery is public and machine-readable — point a standard client at `https://auth.schematize.org/.well-known/openid-configuration` and the keys at `/.well-known/jwks.json`. The normative spec lives in the **schematize-engineering** skill (`references/iam.md`); the Rust specifics in **schematize-rust**."
}