← All posts

An identity provider told us who you were, and we believed it

Authagonal·July 6, 2026
authsecurityssosamloidcfederation

Single sign-on has a quiet premise: when a user arrives from an identity provider, that provider has already vouched for them, so you can skip the password and just let them in. The whole point is to trust the IdP. The bug we're about to describe lived in the gap between "trust the IdP to authenticate its own users" and "trust whatever the IdP tells you about who its users are." Those are not the same sentence, and the difference was an account takeover.

Here's the setup. A tenant on our platform can configure federated connections: SAML to their corporate IdP, OIDC to another. A user signs in through one of those connections, the IdP posts back an assertion, and our server has to answer one question: which local account is this? Get that mapping wrong and you have either a stranger locked out of their own account or, far worse, a stranger walking into someone else's.

The join key was an email, and an email is just a claim

Our code answered "which account is this" the obvious way. The assertion carried an email, so we looked the user up by it: FindByEmailAsync(assertedEmail). If a matching account existed, the returning federated user was resolved onto it and signed in. Clean, simple, and exactly how a lot of SSO integrations are written.

The problem is what that email actually is. It is not a fact the IdP proved. It is a string in an assertion, and the assertion is only as trustworthy as the connection it came through. In a multi-tenant world you do not control every connection. Anyone who can stand up one (their own SAML IdP, their own OIDC provider) can put any string they like in the email field. So a connection you do not trust asserts email = [email protected], our lookup finds the real CEO's account, and the attacker is signed in as them. No password, no phishing, no flaw in the crypto. The signature on the assertion was perfectly valid. It just vouched for a claim we should never have treated as an identity.

The unsettling part is that every individual piece worked. The IdP authenticated its own user correctly. The signature verified. The account existed. The takeover wasn't a failure of any check; it was using the wrong field as the key.

Why more validation is not the fix

The tempting reaction is to validate the email harder. Require email_verified. Check the domain. Add a rule. But notice the shape of the trap: the attacker controls the assertion, so any property you read off the assertion is a property the attacker can set. Demanding email_verified = true from a connection the attacker owns is asking the fox to certify the henhouse. You cannot validate your way out of trusting the wrong source.

The email is fine as a convenience. It is a terrible primary key, because it is global (the same address can show up across many providers) and forgeable (it is whatever the asserting connection says it is). A join key has to be neither.

The fix was to change the key, not to add checks

The real fix was to stop joining on email at all and join on the one thing a connection cannot forge for a provider it does not own: the pair (provider, sub). The sub is the subject identifier the provider issues for that user, scoped to that provider. A returning user is resolved by looking up the local identity previously linked to this connection's (provider, sub). An attacker's connection has its own provider id; it can mint any sub it likes within its own namespace, but it cannot produce the (provider, sub) of someone else's connection. The namespace is the moat.

Email then drops to the role it should always have had: a hint for linking, never for resolving, and only when it is vouched. We match an asserted email to an existing account only when the email is verified by a source we actually trust for that domain: email_verified from a connection whose domain is in the tenant's AllowedDomains. Outside that, a new federated identity gets its own account, not someone else's.

The lesson, factored out

When you federate identity, separate two questions you're tempted to merge. "Did this provider authenticate this user?" is answered by the signature. "Who is this user in my system?" has to be answered by a key the asserting party cannot forge across boundaries, which means a per-provider subject, not a global attribute like email. Treat every field in an assertion as attacker-controlled unless the specific source of that field is one you trust for that specific claim. Email is the field people reach for first because it's human and unique-looking. It is exactly the wrong one.

We shipped this as part of a pre-launch security pass over our own auth server, before anyone trusted us with their logins. It's the kind of bug that passes every test, verifies every signature, and hands over the keys to whoever asks in the right format. The fix wasn't a better lock. It was realizing we'd been reading the wrong line of the ID.

Federating identity safely comes down to joining on a key the asserting party cannot forge, and getting it wrong is an account takeover that passes every test. Authagonal resolves federated users by their per-provider subject, never the email in the assertion.