- architecture
- oauth2
- federation
- engineering
The federation broker pattern — why our authorization hub has no User entity
A pure broker issues tokens but stores no users. Identity-service owns Thoryn-managed users; enterprise IdPs own enterprise users. Here is what it took to enforce in code, schema, and conventions.
13 May 2026 · Mark Bakker
The first time someone reads our authorization hub source code, they tend to ask the same question: "where's the User table?"
There isn't one. The hub has no User entity, no password_hash column, no email field on any table, no Organisation either. By design. Our previous post, Stateless by design, explained why the hub holds no session state. This post is the sequel: it holds no user state either, and the rule that gets us there is enforced in code, in the schema, and in our coding conventions.
The rule
Our coding conventions state the rule explicitly:
The authorization hub is a pure identity broker. It issues tokens but never stores user profiles, credentials, or organisation data. All identity comes from federation members.
Enforcement:
- The hub module must not contain
User,Organisation, or credential entities- Claim enrichment reads claims from the federation member's ID token — it does not query a user table
- If you need user data inside the hub, you are solving the wrong problem: move the logic to the appropriate federation member.
It's stated as enforcement, not guidance. The reason that distinction matters is the next section.
The members
The hub federates to a roster of members. Each member is a separate Spring Authorization Server (or, for SAML and external IdPs, a thin OIDC adapter), each with exactly one concern:
identity-service— Thoryn-managed users. Login, password resets, MFA, account recovery. The single place a Thoryn-issued password hash exists in the entire platform.apple,github-member— social-login adapters; the user data lives at Apple / GitHub respectively, the member just translates their OIDC into ours.saml-member— SAML 2.0 bridge for customers whose corporate IdP doesn't speak OIDC.ldap-connector— adapter for customers running an internal LDAP directory.- Customer-supplied Okta / Azure AD / Google Workspace instances — registered as federation members at deploy time. The user table lives in the customer's tenant; we never see it.
dummy-member— test-only; lets the hub's own test suites run without standing up an IdP. Gated to dev profiles so it can never enable in production.
When a relying party hits /oauth2/authorize, the hub figures out which member should authenticate the user, redirects there, accepts the resulting ID token, mints its own access token from the upstream claims, and forgets the user. The hub brokered the authentication; it never owned it.
What it took to enforce in the schema
A rule that lives only in conventions is a rule that drifts. Last year, the hub's client table had a column named client_settings_federation_members — a comma-separated VARCHAR listing the federation member client IDs each relying party trusted. No foreign keys. Misconfigure a single character, and authorization redirects would silently 500 in production with no clear error path.
A database migration we landed last year replaced that with a proper join table:
CREATE TABLE client_federation_member (
client_id VARCHAR(255) NOT NULL
REFERENCES client(client_id) ON DELETE CASCADE,
member_client_id VARCHAR(255) NOT NULL
REFERENCES federation_member_client(client_id) ON DELETE CASCADE,
PRIMARY KEY (client_id, member_client_id)
);Two foreign keys. A misconfigured federation member ID is now a write-time ForeignKeyViolation, not a runtime 500. The migration also unnests the old comma-separated data into the new table with an INNER JOIN against federation_member_client so any orphaned IDs (typos, deletes, copy-paste errors) get dropped on the way through, and then it drops the old column.
This is what "pure broker" looks like at the schema level: even the configuration about which members a client can use is constrained to point at things that exist.
Claim enrichment without a user table
Relying parties expect claims — email, given_name, tenant_id, role assertions, sometimes custom ones. A traditional authorization server reads them out of its user table when it mints a token. The hub doesn't have one.
It reads them from the upstream member's ID token. Concretely: the hub keeps the upstream iss and sub for the duration of the OAuth flow (in a short-lived FED_TOKEN cookie that expires when the access token is issued), and a small claim-mapping rule per member translates upstream claims to outgoing ones. When an outgoing token needs a tnt claim — our tenant identifier — the claim is minted from the upstream subject, not from a Thoryn-side lookup.
If a customer asks "what claims do you have about user X?", the answer is "none, ask the federation member". PII never lands in the hub's blast radius.
Why this shape
Every architectural decision is a trade. Here's the honest tally.
What you give up. The hub has no native user CRUD. There's no admin UI in the hub for "add a user" or "reset this user's password" — those operations live in identity-service (for Thoryn-managed users) or at the customer's IdP (for federated users). For a tenant whose users come from three different IdPs, "list all users in this tenant" is a federated query, not a single SELECT. We've put work into making this ergonomic at the customer-plane layer, but it's not free.
What you gain. No PII in the hub blast radius. No password hashes stored alongside OAuth client metadata. Tenant data sovereignty composes — a customer running their own Okta keeps their user list in their own jurisdiction. Legal and compliance live at the edge: GDPR data-subject-access requests for a tenant's users go to that tenant's IdP. Thoryn's security review can answer "do you store user data?" with a one-word answer.
The trade is worth it because of how blast radius compounds. A bug in the hub's authorization logic is a serious incident. A bug in the hub's authorization logic plus a hub-side user table is a breach. Removing the table removes the worst-case version of every bug.
What this is not
Worth being honest about three things this pattern doesn't fix on its own:
It does not mean every IdP works automatically. Each new federation member type — a new SAML profile, an unusual claim format, a JIT-provisioning quirk — is real adapter work. We have a recipe directory for this; the easy IdPs are easy and the unusual ones are still unusual.
It does not mean you'll never need a user table. You'll need one in identity-service, because the platform has to ship a usable IdP for customers who don't already have one. That's exactly one user table in the platform — and it's not in the hub.
It does not mean SCIM is solved. Lifecycle provisioning from a customer's directory into a relying party's user model is its own problem. The hub federates authentication; SCIM federates user lifecycle. They overlap but they're not the same thing, and the hub doesn't pretend to be a SCIM endpoint.
The shape that survives
Three years of changes is enough time for a "this rule is silly, let me just add a User table real quick" commit to land in any architecture that doesn't actively prevent it. The combination of the conventions rule, the schema-level join-table constraint, and the per-member separation has — so far — kept that commit from being written. Every time a feature has been tempted to add user state to the hub, it's been a sign that the feature belongs in a member, not in the broker.
If you're building an authorization server today, my recommendation is the boring one: don't store users in it. The constraints get easier the longer you hold them. The constraints get impossible to add later.