276AI

AI Engineering, written as it happens

V1.2.1: I Read My Own Post and Realised I Was Wrong

In v1.2, I wrote about the access control system I built for one of our internal tools. If you want the full context, you can read it here. The short version: when a user logs in, the system takes their email from the JWT, queries the database with it, and returns their permissions record.


Email felt like a reasonable choice at the time. We think of email addresses the way we think of phone numbers: one per person, stable, unique. But in practice, that is rarely true in a corporate environment. Users accumulate aliases across domains, departments, and subsidiary brands. A person who joined under one entity might log in under another. The address changes although the person does not.

When the login alias does not match what is stored, the lookup returns nothing and the user gets denied access. A user registers with john.doe@company.de, but their primary account in Azure AD is john.doe@company-motors.uk. When they log in, that is the email the token carries. The database has never seen it, so access is denied. John has no idea why, because from his perspective he just logged in normally.


The same happens when someone changes their name after a marriage, moves to a different subsidiary, or gets a new domain after a company rebrand. Email comparisons are also case sensitive at the database level, so John.Doe@company.de and john.doe@company.de do not match as strings even though they belong to the same person.


I found this while re-reading the post a few weeks ago, working on something else entirely. There was no open bug, no failing test, no reason to go back. That is when I noticed the decision did not hold up.

The Microsoft Identity Platform documentation is explicit about this. From the ID token claims reference:


“When identifying a user, it’s critical to use information that remains constant and unique across time. Legacy applications sometimes use fields like the email address, phone number, or UPN. All of these fields can change over time, and can also be reused over time.”
“Your application mustn’t use human-readable data to identify a user.”
“To correctly store information per-user, use sub or oid alone (which as GUIDs are unique). If you need to share data across services, oid and tid is best as all apps get the same oid and tid claims for a user acting in a tenant.”

The answer was there. I just had not applied it.

What OID Is
Every user in Azure AD has an Object ID (oid): a stable GUID that identifies them across the entire tenant.

From the same documentation:
“The immutable identifier for an object in the Microsoft identity system, in this case, a user account. This ID uniquely identifies the user across applications.”

An OID looks like this:
4aba6af6-d4c7-4e70-a053-79d4ed0d5d44
It is a UUID, a 128-bit number generated at the moment the account is created in Azure AD and never changed after that. The hyphens separate it into five groups for readability but carry no meaning. There is no name in it, no domain, no company, no role. It is just a number that points to one specific account in the directory.
That is the key difference from email. An email address is human-readable by design: it contains a name and a domain, both of which can change. An OID contains nothing about the person. It just uniquely identifies them, which is the only thing an identifier needs to do.

Regardless of which email alias John uses to log in, Azure AD always attaches the same OID to his token. Whether the token carries john.doe@company.de or john.doe@company-motors.uk, the custom:oid claim is always 4aba6af6-d4c7-4e70-a053-79d4ed0d5d44. The database lookup hits the GSI with that value and finds his record on the first try.

After confirming that OID was the right identifier, the fix had three steps: backfill every existing user record in the database with their OID by calling the Microsoft Graph API, add a Global Secondary Index on that OID field so lookups are fast, and map the objectidentifier attribute from the Azure AD SAML assertion to a custom claim in Cognito so the OID is available directly in the JWT at login time.
The lookup now read custom:oid from the token and queries the GSI. No email involved, no external API call at login time.

Reading your own code a few weeks after writing it is underrated. You are not in the same headspace, you are not trying to fix something urgent, and you actually have time to question the decisions you made. I realised I had linked to documentation that explicitly told me not to use email as an identifier, built the system using email as an identifier, and moved on. The fix was straightforward once I saw the problem. The harder part is creating the habit of going back at all. Not everyone has a blog that forces them to re-read their own decisions, so I guess that is one more reason to start one (Or read mine!).

References

Microsoft Identity Platform — ID Token Claims Reference

Identifying a Unique User: OID vs SUB

Azure Samples — Working with Claims in Multi-tenant Apps


Leave a comment