Blueprint
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.
Machine-readable:/iam-broker.md/iam-broker.json
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.<domain>— 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*andnoneon 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 opaquesuband its lifecycle.verifiable identifiers— email / phone, added, verified and rotated independently ofsub.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.<domain>) 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.<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.
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
- Register the client: a
client_idand an exactredirect_uriallowlist. - On login, redirect to
/.well-known/openid-configuration→authorization_endpointwithresponse_type=code,code_challenge(S256),state,scope=openid. - On the callback, POST to the
token_endpointwith thecode+code_verifier(+client_secretonly for confidential clients). - Validate the
id_token/ access token against thejwks_uri— check signature (asymmetric only),iss,aud,exp,nonce. - 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/noneon 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.