ASVS 5.0 V10.5.2 (Level 2) requires the OIDC client to identify the user from ID Token claims that cannot be reassigned to someone else, normally sub. The identifier is unique only within one identity provider, so the stable key is the pair of issuer and subject.
Why it matters
Emails get recycled when employees leave and domains change hands. A user who signs in as old.name@example.com today might not be the person who held that address last year. If your app joins accounts on email, the new holder inherits the old account and its data.
Email can also be unverified or controlled by the user at some providers, which opens account takeover through a crafted profile.
How to implement it
Store the pair, and look users up on it.
CREATE TABLE identities (
issuer text NOT NULL,
subject text NOT NULL,
user_id uuid NOT NULL REFERENCES users(id),
PRIMARY KEY (issuer, subject)
);
def find_user(claims):
return db.query_one(
"SELECT user_id FROM identities WHERE issuer = %s AND subject = %s",
(claims["iss"], claims["sub"]))
Treat email as a contact attribute. If you ever link accounts by email, require email_verified to be true and a second confirmation step.
First login and account creation
On the first login for a pair of issuer and subject, you either create a user or link to an existing one. Creating is the simple case. Linking is where account takeover happens, so treat it as a sensitive action. Require the user to prove control of the existing account, for example by signing in to it, before attaching the new identity.
def on_login(claims):
row = find_identity(claims["iss"], claims["sub"])
if row:
return row.user_id
user = create_user(display_name=claims.get("name"))
insert_identity(claims["iss"], claims["sub"], user.id)
return user.id
Notice that the code does not look at email at all. If your product needs email for contact, store it as an editable attribute and verify it by sending a message.
Multiple providers and migrations
If you support several providers, the composite key keeps them apart. If you migrate from one provider to another, keep the old mapping and add a new one through a controlled linking step, instead of matching on email. Some providers offer a migration claim for this purpose, and you should prefer it.
Pairwise identifiers
Some providers issue a different sub per client, called pairwise subject identifiers. That is fine, because the value is still stable for your client. Just never compare it with values from another application.
How to test it
Create a user, then have a different identity provider account present the same email with a new sub. Confirm you do not log in as the first user. Change a user's email at the provider and confirm they keep their account.
Data cleanup
When a user leaves the provider or you remove their account, delete the identity rows too. Orphaned mappings are a quiet way for a recycled identifier to land on an old record.
Common mistakes
- Primary key on email.
- Using
subalone when you accept several issuers. - Trusting an unverified email for linking.
Quick check
Why combine issuer and sub?
A sub is only unique within its issuer, so two providers can issue the same value for different people.