In this article
October 1, 2026
October 1, 2026

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.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

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.

  
POST /v2/Query HTTP/1.1
Host: [redacted]
Content-Type: application/json

{"query":"SELECT 1","tableName":"TestData","rowLimit":1}
  

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:

  
{"alg":"none","typ":"JWT"}
  

The token had three sections with the third one empty, so it ended on a bare period:

  
base64url(header).base64url(payload).
  

The payload carried the values Titan wanted and a upn the researcher controlled:

  
{
  "aud": "[redacted]",
  "tid": "[redacted]",
  "appid": "[redacted]",
  "upn": "test@microsoft.com",
  "oid": "00000000-0000-0000-0000-000000000000"
}
  

Tenant, audience and application checks all passed. Then:

  
User 'test@microsoft.com' not found
  

That error was the whole finding. Swapping test@microsoft.com for admin returned 1.

Diagram of Titan's request path. The JWT signature was never checked. Tenant, audience and application ID checks all passed on claim contents alone. The service then looked up the upn claim "admin" in its local user table, which resolved to user ID 1 with the Admin role, and the SQL query ran as admin.

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:

  
17,333,335,124,315
  

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."

ClaimStable and never reused?Safe as a lookup key?Use it for
iss + subYes, guaranteed by OpenID ConnectYesLinking an external identity to a local account
oid (Entra)Yes, per tenantYes, together with tid or issSharing one user's data across your services
tid (Entra)YesOnly as part of a keyRouting, sharding, tenant allowlists
upnNo: changes on rename, can be reusedNoDisplay and audit
emailNo: mutable, may be reassignedNoDisplay, contact prefill
preferred_username, nameNoNoDisplay only

‍

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:

  
// Don't do this.
const claims = await verifyToken(bearer);               // signature: fine
const user = await db.users.findByEmail(claims.email);  // identity: not fine
return authorize(user.role);
  

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.

Comparison of two ways to link federated identities to local accounts. When accounts are linked on upn or email, three different people over time (the original employee, a new hire given a reused email address, and a guest with the same UPN) all resolve to one local row with the Admin role. When accounts are linked on issuer plus subject, each person has a distinct key and gets their own row.
Account linking on a composite issuer-plus-subject key gives every local row exactly one owner. Linking on a human-readable claim leaves rows reachable by anyone who can hold that string.

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:

  
create table identities (
  id             bigserial   primary key,
  user_id        bigint      not null references users(id),
  issuer         text        not null,  -- the iss claim, verbatim
  subject        text        not null,  -- the sub claim (or oid), verbatim
  email_at_link  text,                  -- display and audit only
  linked_at      timestamptz not null default now(),
  unique (issuer, subject)
);
  

Resolution then has exactly one path, and it runs after verification, never before:

  
async function resolveUser(token: string) {
  // Pinned algorithms, issuer keys, aud, exp, and your tenant allowlist.
  const claims = await verifyToken(token);

  const link = await db.identities.findUnique({
    where: { issuer_subject: { issuer: claims.iss, subject: claims.sub } },
  });

  if (link) return db.users.findById(link.user_id);

  // No link for this (iss, sub). Provision a new account, or hand off to an
  // explicit linking flow where a signed-in user proves they own the target
  // account. Never auto-link on email, upn, preferred_username, or name.
  return provisionUser({ issuer: claims.iss, subject: claims.sub });
}
  

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:

StepWhat to checkTitan
1. ProvenancePinned algorithms, keys that belong to the issuer, a verified signatureSkipped
2. Envelopeaud matches your own application ID, plus iss, expiry, and your tenant allowlistChecked, on unverified claims
3. IdentityResolve the local account on (iss, sub) and nothing elseResolved on upn
4. Every stepTreat any claim that reaches a query as untrusted inputClaim went straight into a user lookup

‍

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:

  
try {
  const user = await resolveUser(bearer);
  return handle(request, user);
} catch (err) {
  // Detailed reason for your logs: bad signature, wrong aud, unknown tenant...
  log.warn("auth_failed", { reason: err.code, requestId: request.id });
  // One response for every failure, so the caller learns nothing per check.
  return new Response(null, { status: 401 });
}
  

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.