When an application uses a mutable claim—like email or display name—instead of the immutable oid to identify users after OAuth login, a single pre-registered account can become a skeleton key to anyone's session. This is the story of how I first encountered this vulnerability, and what every developer implementing "Log in with Microsoft" needs to know.
Julian Morley
A few years ago I was working with a digital signage company—the kind of business that puts interactive kiosks in shopping centres and corporate lobbies. They had built a reasonably polished SaaS portal where customers could manage their displays, upload content, and configure schedules. They had also, sensibly they thought, integrated Microsoft Entra ID (then still called Azure AD) as an SSO option. The familiar blue button appeared on their login page: Log in with Microsoft.
Then a bug bounty hunter filed a report against their demo environment. The finding was marked critical. Did the engineering team patch the bug? Not sure because most companies still run the "if it aint broke, don't fix it" mindset.
The vulnerability was not exotic. It did not require zero-days, social engineering, or physical access. It required knowing a target's email address, creating a free Microsoft account with that same address, and clicking the blue button. That was it. The attacker walked straight into the victim's account—including whatever roles and content configuration that account had accumulated.
It is, in my experience, one of the most common and least discussed authentication vulnerabilities in enterprise SaaS applications—and it lives in a line of code that most developers never think twice about.
To understand the vulnerability, you first need a clear picture of the flow. When a user clicks Log in with Microsoft, the application redirects them to Microsoft's authorization endpoint. After the user authenticates, Microsoft issues an authorization code—a short-lived, one-time credential—and redirects the browser back to the application's registered callback URI. The application then exchanges that code for tokens at Microsoft's token endpoint (Microsoft, 2026).
The payload that matters most for identity is the ID token—a signed JSON Web Token (JWT) that describes the authenticated user. It contains "claims": key-value pairs that assert facts about the user. The application's job, once it has validated the token's signature and expiry, is to look at those claims, figure out who just logged in, and either create a local session or map the incoming identity to an existing user account.
That mapping step is where the vulnerability lives.
Microsoft's ID token contains a rich set of claims, but they are not all equal in terms of stability. Some claims describe the user's current state in the directory. Others are computed, immutable identifiers that are guaranteed to be permanent and unique.
Microsoft's own documentation is explicit on this point:
"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." (Microsoft, 2025a)
The claims you should trust for identity correlation are:
oid (Object ID): A GUID that uniquely identifies the user account within the Entra ID tenant. It is immutable—it does not change if the user changes their name, their email, or their username. It persists even if the user's account is disabled and re-enabled. Microsoft Graph uses this same value as the id property for a user object.sub (Subject): Also immutable, but deliberately scoped to a specific application. Two different applications will see different sub values for the same user, making it suitable for per-app identity storage without cross-app correlation.tid (Tenant ID): Identifies which Entra ID tenant the token came from. Critical for multi-tenant applications.Everything else—in particular email, preferred_username, upn, and the name claim—is mutable. A user can change their display name. An IT administrator can reassign an email address to a new employee after the previous one leaves. The preferred_username in v2.0 tokens is documented explicitly as something that "might change over time" and "can't be used to make authorization decisions" (Microsoft, 2025a).
The practical implication is straightforward: if your application stores a user record keyed on email and performs its lookup like this—
SELECT * FROM users WHERE email = :email
—then your authentication is only as secure as the email address itself.
Here is the attack in concrete terms. Assume your application's SSO callback logic looks roughly like this (pseudocode):
claims = validate_and_decode_id_token(token)
user = db.find_user_by_email(claims["email"])
if user is None:
user = db.create_user(email=claims["email"], ...)
session.login(user)
This pattern is extremely common. It is also what the digital signage company had. And here is the exact sequence an attacker exploits:
alice@contoso.com has an administrative account in the target application. Her address might be visible in a company directory, found through LinkedIn, or simply guessed.alice@contoso.com as the email address. Microsoft personal accounts allow this. Importantly, the oid in this new account is completely different from Alice's real corporate oid—it belongs to the attacker.email: alice@contoso.com—and their own, different oid.users WHERE email = 'alice@contoso.com', finds Alice's legitimate account, and creates a session for it. The attacker is now logged in as Alice—with all of Alice's permissions, roles, and data.No credentials were stolen. No phishing occurred. The identity provider did everything correctly. The flaw was entirely in how the application decided to correlate an authenticated identity with a local user record.
The attack works equally well if the application uses preferred_username, name, or a display name field instead of email. Any claim that the attacker can influence or predict—and that the target application treats as the primary key—becomes a vector.
Carmel (2023) documented analogous patterns in OAuth implementations at Grammarly, Vidio, and Bukalapak, where access token reuse across applications enabled account takeover for hundreds of millions of users. The root cause in each case was the same conceptual error: applications trusted user-supplied or cross-context identity attributes rather than verifying a canonical, application-specific identifier.
The scenario above describes taking over an existing ordinary account. When combined with role-based access control, the blast radius grows considerably.
In many SaaS applications, accounts are provisioned—and assigned roles—before the user ever logs in. An IT administrator grants alice@contoso.com the "Content Manager" or "Billing Administrator" role in the application's database, then tells Alice to log in. The application is designed to welcome her on first SSO login by finding the pre-created record using her email.
This provisioned-but-not-yet-logged-in account is exactly the state an attacker wants to find. By striking before Alice, the attacker inherits all the roles that were pre-assigned, without needing to know what those roles are. In the digital signage context this meant access to display configurations and content. In a financial or HR SaaS product the stakes are considerably higher.
Even without pre-existing role assignment, an attacker who controls an account can accumulate privileges over time—or wait for the real user to be granted escalated access—and then re-exploit the trust at a more opportune moment.
The remediation is not complicated, but it requires deliberate intent. The core principle from Microsoft's documentation is: use oid and tid together as the canonical compound key (Microsoft, 2025a). Not email. Not upn. Not a display name.
A corrected version of the lookup above looks like this:
claims = validate_and_decode_id_token(token)
oid = claims["oid"]
tid = claims["tid"]
user = db.find_user_by_oid_and_tid(oid, tid)
if user is None:
# New user — create a record anchored on oid+tid
user = db.create_user(
oid=oid,
tid=tid,
email=claims.get("email"), # store for display, never for lookup
display_name=claims.get("name"),
...
)
session.login(user)
The email and display name can still be stored and displayed—they are useful for human-readable UI. But they must never be used for the security-critical lookup that determines which account receives the authenticated session.
For multi-tenant applications, the tid is essential because oid values are scoped per tenant. The same physical person authenticated across two different Entra ID tenants will have two different oid values—by design, to prevent cross-tenant tracking (Microsoft, 2025a). Using oid + tid as a compound key correctly models this.
Beyond the claims check, there are several complementary controls worth verifying in any SSO implementation:
Validate the iss (issuer) claim. For single-tenant applications this should be locked to https://login.microsoftonline.com/{your-tenant-id}/v2.0. For multi-tenant applications, the issuer validation is more nuanced, but you should at minimum ensure you are rejecting tokens from tenants you do not intend to serve. Accepting tokens from https://login.microsoftonline.com/common/v2.0 without further validation means any Entra ID tenant in the world can produce a valid token for your application.
Validate aud (audience). The ID token's audience claim must match your application's Client ID. This prevents token reuse across applications—the class of attack documented by Salt Security's research (Carmel, 2023).
Use PKCE. The Proof Key for Code Exchange extension prevents authorization code interception attacks in public clients. Microsoft's documentation marks it as required for single-page applications and recommended for all client types (Microsoft, 2026). If you are using an older implementation that omits it, add it.
Use Microsoft's identity libraries. MSAL (Microsoft Authentication Library) handles token acquisition, signature validation, and claim extraction correctly by default. Hand-rolled JWT parsing is where subtle mistakes—like trusting unsigned token segments, or skipping expiry checks—tend to appear. Stepankin (2021) catalogued a number of these subtle misconfigurations across open-source OAuth server implementations; the common thread was developers re-implementing protocol logic that the library would have handled correctly.
Keep libraries updated. OAuth and OIDC libraries occasionally patch security issues in their validation logic. Pinning dependency versions without monitoring for updates is a quiet way to inherit known vulnerabilities.
If you maintain an application with Microsoft SSO integration that you did not write from scratch—or one that has grown through many developers over time—it is worth doing a targeted search for this specific pattern.
Start in the token callback or OIDC middleware handler. Find the code path that runs after a successful authentication and look for the first database interaction. Ask one question: what field is the query filtering on?
# Bad — any of these as a lookup key
db.query("SELECT * FROM users WHERE email = ?", claims["email"])
db.query("SELECT * FROM users WHERE username = ?", claims["preferred_username"])
db.query("SELECT * FROM users WHERE display_name = ?", claims["name"])
# Also bad — using a custom claim that might be set by the calling app
db.query("SELECT * FROM users WHERE external_id = ?", claims["custom_attr"])
# Good
db.query("SELECT * FROM users WHERE oid = ? AND tid = ?", claims["oid"], claims["tid"])
Custom claims deserve particular attention in enterprise integrations. Applications sometimes add custom attributes to the Entra ID token via optional claims configuration or through a claims-mapping policy. These can be useful, but if they contain values derived from directory attributes—especially ones that can be modified by the user or a delegated admin—they carry the same risk as built-in mutable claims.
Also review your user provisioning flow. If your application supports just-in-time provisioning (creating a user record on first login), verify that the provisioned record's primary key is the oid—not the email, even if email is the address used to send the welcome notification. A mismatch here is exactly what makes the pre-registration attack possible.
Finally, check the flow for pre-provisioned accounts—records created by an admin before the user's first login. If those records are keyed on email and a matching oid is never stored until the first actual login, there is a window during which the pre-registration attack applies. The simplest remediation is to store the oid at provisioning time (retrieved via Microsoft Graph using the admin's access token) rather than waiting for the user's first login to populate it.
What strikes me most about this class of vulnerability is how naturally it emerges from good intentions. The developer who wrote the find_user_by_email lookup was almost certainly thinking: "Email is how we identify people. Microsoft has verified this person's email. Therefore, looking them up by email makes sense."
The reasoning is coherent, and in a world where identity providers guaranteed email address uniqueness and immutability, it would even be correct. But email addresses are not uniquely owned—they can be shared across providers, reassigned after account deletion, or registered on personal Microsoft accounts pointing to any address the registrant claims. The oid exists precisely because the identity provider understands its own data model better than any downstream attribute: it is the primary key for a user object in Entra ID's own database.
Microsoft makes this explicit in their claims reference: "Your application mustn't use human-readable data to identify a user" (Microsoft, 2025a). That guidance has been in the documentation for years. It still gets missed.
The digital signage company fixed the bug within hours of the proof-of-concept. The patch was a handful of lines. The pre-existing user records were migrated to store oid + tid as the lookup key, and the callback handler was updated accordingly. No public disclosure was required because the vulnerability was found on a demo environment with no real customer data.
But the reason it existed in the first place—a reasonable-seeming assumption about how identity works, made early in the project and never revisited—is identical to the reason it exists in other applications today. If you have a Log in with Microsoft button, and you have not explicitly verified which claim your user lookup queries against, this is worth fifteen minutes of your time.
The blue button is not the attack surface. The line of code that runs after it is.
Carmel, A. (2023, October 24). Oh-auth — abusing OAuth to take over millions of accounts. Salt Security. https://salt.security/blog/oh-auth-abusing-oauth-to-take-over-millions-of-accounts
Microsoft. (2025a). ID token claims reference. Microsoft Entra documentation. https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference
Microsoft. (2025b). Security best practices for application properties in Microsoft Entra ID. Microsoft Entra documentation. https://learn.microsoft.com/en-us/entra/identity-platform/security-best-practices-for-app-registration
Microsoft. (2026). Microsoft identity platform and OAuth 2.0 authorization code flow. Microsoft Entra documentation. https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow
Stepankin, M. (2021, March 24). Hidden OAuth attack vectors. PortSwigger Research. https://portswigger.net/research/hidden-oauth-attack-vectors
Infrastructure as Code: The Terraform Supremacy and Its Challengers
A critical examination of Infrastructure as Code tools in 2025, with Terraform's dominance facing serious competition from Pulumi, CloudFormation, and emerging alternatives.
SDET: The Unsung Heroes of Enterprise Software Quality
Why Software Development Engineers in Test represent the future of quality assurance, and why enterprises that don't invest in SDET talent are setting themselves up for failure.