Microsoft Titan JWT bug: Never use upn as a local username
A researcher reached an internal Microsoft service by setting a JWT's upn claim to admin. The missing signature was the door. The lookup key was the bug.
Shortly after 1 AM on Saturday, September 5, 2026, a 16-year-old bug hunter sent an internal Microsoft analytics service a JWT with no signature and the upn claim set to admin. The service looked up admin in its own user table, found local user ID 1, which held the Admin role, and ran his test query. The answer came back as a single 1, the result of SELECT 1, which meant he was now executing SQL as the service's administrator. He reported it to Microsoft the same day. Microsoft locked the endpoint down on September 9 and paid a $5,000 bounty on September 17.
The missing signature check is the door. The bug worth copying into your next code review is the line behind it: the one that took a string from another company's identity system and used it to look up a row in a local user table. If you only have a minute, that is the whole lesson: a federated claim like upn or email is never a local username. Key local accounts on the issuer plus subject (iss + sub) and nothing else.
What did Titan check, and in what order?
The service was called Titan, and the researcher's personal AI hackbot, Antares, surfaced it on August 25. Its web interface sat behind a VPN gate for Microsoft employees, so the frontend was unreachable from outside. The API was not. A public Swagger file listed four routes: /GetConfiguration, /GetOnboardedTables, /v2/Query, and /v2/Insert. It specified Azure AD bearer authentication for three of them. The exception was /v2/Query, which also happened to be the route that accepted raw SQL. Archived 2023 snapshots of Titan's login and privacy pages yielded a Superset configuration with 56 table definitions, including a routing value named TestData.
With no authorization header, that returned 401 Unauthorized. Over the following ten days the hackbot came back to Titan again and again, advancing one error message at a time. A token from the researcher's own external Entra test tenant produced a tenant error. Changing the tenant to Microsoft's produced an audience error. Changing the audience produced an application allowlist error. Changing the application ID reached a user lookup.
Every one of those steps edited the payload and left the signature untouched, and Titan kept accepting the new claims. In the researcher's words:
"The payload kept changing while the signature stayed exactly the same, and Titan kept accepting the new claims, like a bouncer checking the name on every ID but never looking at the photo."
So the next token dropped the signature entirely. The header declared no algorithm:
The token had three sections with the third one empty, so it ended on a bare period:
The payload carried the values Titan wanted and a upn the researcher controlled:
Tenant, audience and application checks all passed. Then:
That error was the whole finding. Swapping test@microsoft.com for admin returned 1.

From there, SHOW DATABASES exposed Titan's platform metadata database and its application tables: roughly 25,000 account and email records, 17,990 employee email records, 15,001 employee organization records, 355 database configurations, 20,979 virtual-dataset SQL definitions, and 24,569 dashboards alongside 425,891 charts. Thirty of the 56 archived routing values were still live, resolving through 24 configurations to 17 connected analytics databases across 9,863 unique table names. Summing ClickHouse metadata across those databases through two independent paths produced the same total both times:
Read that number with the caveats the researcher attached to it. It is a storage estimate from metadata that likely includes historical, duplicated and derived data, and the described impact is hypothetical. No customer data or PII was touched, and the scope came from table descriptions, metadata and two bounded sample rows. Microsoft also had editorial control over the researcher's write-up, cutting sections and figures and reshaping the impact description before publication, so read the published picture as the version Microsoft agreed to.
The known half of the bug
alg: none is the first threat in the JWT Best Current Practices (RFC 8725): "The algorithm can be changed to 'none' by an attacker, and some libraries would trust this value and 'validate' the JWT without checking any signature." The mitigations are just as old. Pin the acceptable algorithms at the call site and refuse everything else, and don't consume none tokens unless the caller explicitly asked for that. We have covered the mechanics and the library-level fixes before.
What makes Titan worth a second post is what came after the gate.
Why isn't upn a safe lookup key?
Titan used the upn claim as a local username. admin matched local user ID 1, which held the Admin role, and the SQL executed. The researcher's note on why ten days of automated probing never landed it: "admin is obvious, but obviously not a valid UPN. That's exactly why Antares never guessed it."
That sentence is the bug stated precisely. upn is a name in Microsoft's namespace. admin was a name in Titan's namespace. The lookup joined the two on a raw string, so a value with no owner at the issuer resolved cleanly to the most privileged row on the resource.
Microsoft's own ID token claims reference says not to do this:
"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. For example, when an employee changes their name, or an employee is given an email address that matches that of a previous, no longer present employee. Your application mustn't use human-readable data to identify a user."
The documented alternative is sub or oid, with tid for routing or sharding. oid is immutable and identifies the same user across applications; sub is immutable, never reassigned, and pairwise per application. OpenID Connect says the same thing normatively:
"The sub (subject) and iss (issuer) Claims, used together, are the only Claims that an RP can rely upon as a stable identifier for the End-User, since the sub Claim MUST be locally unique and never reassigned within the Issuer for a particular End-User, as described in Section 2. Therefore, the only guaranteed unique identifier for a given End-User is the combination of the iss Claim and the sub Claim."
The JWT BCP flags the general pattern too. Its section titled "Do Not Trust Received Claims" notes that values from the token feed lookups such as database and LDAP searches, which makes each one an attacker-controlled input to that query. Its worry there is injection. Titan's version was quieter: the query was well-formed and the lookup succeeded.
Does a correct signature check fix it?
Assume Titan had been fixed properly at the crypto layer: algorithms pinned, signature verified against Microsoft's published keys, keys confirmed to belong to the issuer in the iss claim as the BCP requires. The admin row is still sitting there with no owner in Entra, and the question becomes a different one. Who, legitimately, can cause a correctly signed token for a tenant you trust to carry upn: admin?
That list is longer than it looks, and it grows without your involvement. UPNs change when people are renamed and get reused when addresses are recycled. Guest users are homed in one tenant and authenticate in another, which is why Microsoft tells you to treat a guest "as if they're a brand new user to the service" rather than as whoever their claims resemble. OpenID Connect explicitly allows issuers to reuse an email value across different end-users at different points in time.
Titan's authorization logic was fine. The defect sat upstream of it, in how a claim became a local account. The generic form is three lines of application code that nobody flags in review:
The strongest objection is that forging claims inside a trusted issuer is hard, so keying on upn after a real signature check is acceptable in practice. That answer treats the threat as forgery only. Plenty of ordinary mechanisms move a human-readable string from one person to another: renames, recycled addresses, guest invitations, claims mapping rules, directory sync with a writable UPN attribute. Signature verification tells you a token is authentic. It tells you nothing about whether the person behind it is the person who owns your row.

How should you link federated identities to local accounts?
Give every local account an explicit set of external identities, each keyed on the pair the issuer actually guarantees:
Resolution then has exactly one path, and it runs after verification, never before:
Human-readable claims still belong in your schema. They belong there as display data you refresh on every login, not as anything a WHERE clause resolves on. Microsoft is blunt about the email claim specifically: it "isn't guaranteed to be correct, and is mutable over time," so never use it "for authorization or to save data for a user."
The order of the checks is what Titan got wrong, and it is short enough to put on a review checklist:
If you want the details on step 2, we have posts on the iss and aud claims.
Why do verbose auth errors matter now?
Titan told the caller which check had failed, one check at a time: tenant, then audience, then application allowlist, then user lookup. A human bored by the fourth retry quits. An agent that never gets bored does not, and this one kept coming back for ten days while working other leads.
The hackbot still didn't finish the job. Running Codex and Claude on the lead, it correctly inferred from the field name that upn wanted an email-formatted Entra identity, so it kept testing placeholders, published service aliases and employee-style addresses, and never got a working query. The step it couldn't take was deciding that the field name was a lie: that whoever wrote the backend had treated upn as a local username. That took a person at 1 AM on a Saturday.
So collapse every failure between provenance and identity resolution into one indistinguishable 401, and keep the specific reason in your server logs, where your team can read it and a caller can't:
Verbose authentication errors are free, tireless reconnaissance now. And the mistake the agent couldn't find on its own is the mistake still sitting in your codebase: go search for the place where a claim from someone else's identity provider becomes a lookup key in yours.