Proofpoint documented two campaigns this year that validate stolen credentials against Entra ID without ever producing a successful sign-in. The technique is simple enough to explain in a sentence: send a password to the token endpoint with a client ID that doesn’t correspond to any registered application, and read the error you get back. These error codes matter, but the bigger leak is where each code sits in the authentication sequence.
Three codes, three different meanings
The requests go to the OAuth 2.0 Resource Owner Password Credentials (ROPC) endpoint: a POST carrying username, password, and client_id. This technique is scoped to ROPC explicitly, and restricting it is among the mitigations.
Three responses matter:
- AADSTS50034 (
UserAccountNotFound): The account isn't in the directory. - AADSTS50126 (
InvalidUserNameOrPassword): Documented as a catch-all covering either failure. - AADSTS700016: The application identifier wasn't found in the directory.
AADSTS50126 shouldn't discriminate between a bad username and a bad password, but the observed behavior separates them. A failed attempt against a nonexistent username is not written to the sign-in log at all, because Entra only writes a log row once it has resolved a username as valid. Entra returns 50034 for absent accounts and 50126 for present accounts with an incorrect password, which is an observed behavioral distinction that Microsoft's documentation blurs. The scoping matters too. These observations apply to managed tenants at the token endpoint. Federated tenants validate passwords on-premise through Pass-through Authentication (PTA) or ADFS, so the error path differs.
The logs prove the differences in handling:
Request | Log row written? | What the attacker learns |
|---|---|---|
|
Account not in directory (50034)
|
No |
This username doesn't exist |
|
Account exists, wrong password (50126) |
Yes, failed sign-in |
This username exists |
|
Account exists, correct password (700016) |
Yes, failed sign-in
|
This credential pair works |
AADSTS700016 reads as an app registration being broken. Seen after a stream of authentication attempts with unfamiliar client IDs, it confirms the attacker holds a working password.
The two campaigns
UNK_pyreq2323 ran out of AWS infrastructure from January 14, 2026, to early March. It generated over 700,000 spoofed client IDs by randomizing the final six digits of a legitimate first-party Exchange Online application identifier (e.g. 00000002-0000-0ff1-ce00-000000XXXXXX). It targeted more than a million accounts across roughly 4,000 tenants and reused each spoofed ID against no more than 12 accounts before discarding it. A first-party Microsoft application ID is pre-consented in every tenant, so IDs that keep that prefix look like they belong rather than like noise.
UNK_OutFlareAZ appeared in December 2025 and returned in February and March 2026, running through Cloudflare infrastructure against more than 2 million users with 3.7 million spoofed application IDs.
The two used different generation methods, and the difference matters for detection. Where pyreq2323 kept a real prefix and reused each ID up to 12 times, OutFlareAZ generated a fresh random UUIDv4 per request, one user per ID, so any heuristic keyed to reuse misses it.
(Proofpoint tracks both campaigns under its UNK_ designation, which marks unattributed clusters rather than named actors.)
Detection
Defenders saw the failures and the lockouts, but they couldn’t tell which of those failures were credential confirmations. The signal was there, sitting in the same pile as every user who fat-fingered a password that morning, but it was unreadable without knowing which error code to separate out. The genuinely invisible half is the enumeration. Nonexistent usernames produce no log row at all, so the reconnaissance that precedes everything else leaves nothing behind in any tenant.
Every one of these requests lands in the interactive sign-in logs. That follows from Microsoft's own definition: IsInteractive covers sign-ins where the user supplies an authentication factor, the definition names passwords explicitly, and ROPC submits a password.
The signature is a blank application name:
Client ID in request |
|
|
|---|---|---|
|
Real registered app |
populated |
populated |
|
Well-formed UUID, unregistered
|
populated
|
blank |
|
Malformed client ID |
blank |
blank |
Row two is what you're hunting. Pair isnotempty(AppId) with isempty(AppDisplayName) so you isolate the well-formed-but-unregistered case rather than also catching malformed junk.
The query that matters returns confirmed credential compromises:
SigninLogs
| where TimeGenerated > ago(30d)
| where ResultType == "700016"
| where isnotempty(AppId) and isempty(AppDisplayName)
| project TimeGenerated, UserPrincipalName, AppId, IPAddress,
UserAgent, AuthenticationProtocol, ResultDescription
| order by TimeGenerated asc
Every row is a credential pair Entra validated, so treat the output as a password-reset and session-revocation list. It is not an app-registration ticket, and the single most consequential mistake available here is routing 700016 hits to whoever owns application registrations.
For campaign detection, pivot on source rather than on client ID:
SigninLogs
| where TimeGenerated > ago(7d)
| where isnotempty(AppId) and isempty(AppDisplayName)
| summarize
DistinctAppIds = dcount(AppId),
TargetedUsers = dcount(UserPrincipalName),
ConfirmedCreds = countif(ResultType == "700016"),
WrongPassword = countif(ResultType == "50126"),
Lockouts = countif(ResultType == "50053"),
UserAgents = make_set(UserAgent, 10)
by IPAddress, bin(TimeGenerated, 1h)
| where DistinctAppIds > 10
| order by DistinctAppIds desc
Pivoting on IPAddress is deliberate. Any heuristic built on the 12-account reuse ceiling fingerprints pyreq2323 and misses OutFlareAZ entirely, because the second never reuses an ID. Both campaigns look the same from the source: one origin presenting a large number of client IDs that resolve to nothing. Keeping ConfirmedCreds and Lockouts in the same row shows the noise next to the real compromises.
What the fix does and doesn't cover
Restricting ROPC through Conditional Access is the right move and Proofpoint recommends it. But I want to be clear about what it buys.
It doesn’t touch enumeration. AADSTS50034 is returned when Entra resolves the username, before any credential validation and two steps before policy evaluation. No Conditional Access policy can intercept a response issued before policy is consulted, and no log row is written regardless of tenant configuration. Username enumeration against your directory survives every tenant-side control, including a hard legacy-authentication block.
A policy blocking legacy authentication returns AADSTS53003, BlockedByConditionalAccess. By the same ordering logic, 53003 is only reachable once the password has been validated, so the attacker's error message becomes 50126 versus 53003. Different code, same information.
References
Share on
Learn More
About the author
Darryl Baker
Senior Staff Security Researcher
Darryl G. Baker is a Senior Staff Security Researcher at Netwrix and a recognized authority in Identity and Active Directory security. With over a decade of identity systems experience, he has led enterprise security assessments, identity security trainings, and threat emulations focused on Active Directory, Entra ID, and Azure environments. Darryl has delivered highly rated trainings and demos at BlueTeamCon, BSidesCT, The Experts Conference, and Wild Wild West Hackin’ Fest. He’s the architect behind numerous hands on attack emulation labs—leveraging current red team and blue team tools to help defenders master everything from attack path analysis to threat hunting. In his sessions, Darryl blends deep technical insight with real world case studies, empowering blue team professionals to strengthen their identity security posture and defend against evolving adversary techniques.
Learn more on this subject
Your password vault provider isn't your backup plan
To self-host or not to self-host your password manager
Static credentials are still AI's easiest way in
AI hacking makes password spraying faster. Here's how to close the gap
Self-hosted password vault: why security teams are taking the keys back