# schematize blueprints — full corpus > Every blueprint, in full, in one file. Individual pages at //, per-blueprint Markdown at /.md. # How to build an IAM broker > 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. > Source: https://blueprint.schematize.org/iam-broker/ 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. ## What an IAM broker is An 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. One front door. Identity, factors, sessions and risk live in exactly one place; everything else is a *relying party*. ## Why it's essential - **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. - **It centralizes what has to be consistent:** MFA, session revocation, risk scoring, audit, passwordless rollout, tenant isolation. Change it once; every app benefits. - **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. ## The non-negotiable floors These are the house rules. Skipping one isn't "moving fast" — it's shipping a breach. - **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. - **Auth is a separate app, from day 0.** The broker is `auth.` — its own service, its own deploy, its own database. Never a module inside the monolith. - **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. - **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. - **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. - **Deny-by-default, everywhere.** Unknown client, unlisted `redirect_uri`, missing PKCE, empty input → rejected. Allow is the exception you write down. - **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. - **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. ## Architecture The broker is a service with a handful of internal modules and two kinds of consumers. **Inside the broker** - `identity` — the opaque `sub` and its lifecycle. - `verifiable identifiers` — email / phone, added, verified and rotated independently of `sub`. - `factors` — password (argon2id + HIBP), Email OTP, TOTP (RFC 6238), passkey (WebAuthn). - `risk` — adaptive scoring, step-up, deceptive denial, honeypots. - `session` — multi-device sessions and irreversible logout. - `token` / `keys` / `jwks` — issuance, the signing key, and the public JWKS. - `providers` — email / SMS / push senders (a **sink by default** outside production, so no test ever emails a real person). **Outside the broker** - **Clients (relying parties)** are OIDC clients using **Authorization Code + PKCE**. They redirect to the broker and get a token back. - **Resource servers (APIs)** validate that token against the broker's **JWKS** — they *never* call the broker per request. **Discovery** ties it together: publish `/.well-known/openid-configuration` and `/.well-known/jwks.json`, and any standard client can integrate without bespoke code. ``` app (client) broker (auth.) api (resource server) | 1. redirect /authorize (PKCE) → | | | ← 2. code (redirect_uri) | | | 3. POST /token (code+verifier) → | | | ← 4. id_token + access + refresh | | | | 5. GET /jwks (cached) ← | | 6. call API with access token ───────────────────────────────→ | | | 7. verify JWT via JWKS | ``` ## Build it: step by step ### 1. Stand it up as its own service Own repo, own Linux user, own deploy at `auth.`. 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. ### 2. Model identity (≠ email) `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. ### 3. Factors — 2FA baseline, no pre-login wall **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. ### 4. Tokens — sign asymmetric, rotate refresh Issue **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**. ### 5. Sessions & a logout that means it Track 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. ### 6. A risk engine, not a binary Score 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**. ### 7. Authorization is downstream The 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. ### 8. Wire the clients (relying parties) An 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`. ### 9. Operate it Signing 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**. ## Integrating a client — the short version 1. Register the client: a `client_id` and an **exact** `redirect_uri` allowlist. 2. On login, redirect to `/.well-known/openid-configuration` → `authorization_endpoint` with `response_type=code`, `code_challenge` (S256), `state`, `scope=openid`. 3. On the callback, POST to the `token_endpoint` with the `code` + `code_verifier` (+ `client_secret` only for confidential clients). 4. Validate the `id_token` / access token against the `jwks_uri` — check signature (asymmetric only), `iss`, `aud`, `exp`, `nonce`. 5. Store the session server-side behind an HttpOnly cookie. For APIs, verify the access token per request against the cached JWKS. ## Anti-patterns (rejected on purpose) - **Email as the primary key.** Breaks the day someone changes it; leaks identity into every foreign key. - **Per-app login / copy-pasted auth.** N inconsistent surfaces and no central revocation. - **A verification wall before the first login** — the infinite circle. - **Token in `localStorage`.** One XSS and it's exfiltrated. HttpOnly cookie, always. - **Accepting `HS256` / `none` on validation.** Key-confusion in a box. Asymmetric only. - **An LLM (or the client) deciding authorization.** Enforcement is deterministic and server-side; a model's opinion is not a permission. - **Distinguishable "wrong password" vs "unknown user."** That's an account-enumeration oracle — same answer, same timing. ## Reference implementation The 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**.