§ AC

OAuth Consent Phishing in Entra ID: The Persistence Layer FIDO2 Doesn’t Touch

FIDO2 enrollment programs ate most of 2024 and 2025. A lot of shops finished those programs and quietly assumed the identity problem was, if not solved, at least bounded. It isn’t. The offensive crews that used to lean on AiTM token theft have shifted weight toward two flows that route around authentication strength entirely: OAuth consent grants and device code phishing. Both end in a refresh token sitting in attacker infrastructure. Neither cares that the user authenticated with a YubiKey. Neither is broken by a password reset. Both are still under-detected in the average enterprise tenant in 2026, even where the SOC has Defender for Cloud Apps and the full Entra ID P2 SKU.

This is a defender piece. I’m going to walk the mechanism, then the detection as it actually looks in Sentinel (or Splunk, if you’re shipping the AAD diagnostic logs into HEC), then the tuning fights you’ll have in the first two weeks, then control mapping. No payloads, no operator workflow — there’s enough of that on every conference stage already.

The mechanism, briefly

The attack abuses the OAuth 2.0 authorization code flow against a multi-tenant application registered in an attacker-controlled tenant. The user is phished to a legitimate login.microsoftonline.com URL — that’s the part that makes this hard to train against, the domain is real — and prompted to consent to scopes like Mail.Read, Files.Read.All, offline_access, and User.Read. If the tenant’s consent policy permits user consent for those scopes, the user clicks Accept. A service principal is now provisioned in the victim tenant, and the attacker holds a refresh token bound to that service principal, not to the user’s session.

That refresh token is the prize. It survives MFA challenges (already passed). It survives password rotation. It survives FIDO2 enrollment. It survives revoking the user’s sessions through the normal Entra portal flow, because session revocation targets the user’s interactive sessions, not the service principal’s token cache. The refresh token only dies when someone explicitly revokes the service principal or runs Revoke-MgUserSignInSession and invalidates the app’s consent. Most IR runbooks I’ve read still don’t include that second step.

The device code variant is mechanically different but lands in the same place. The attacker initiates a device code flow against the Microsoft Graph or Azure CLI client IDs (both are first-party, both are trusted, neither triggers a consent prompt for the user), gets a user_code, and gets the victim to paste it into microsoft.com/devicelogin. The victim completes auth — including FIDO2 — and the resulting token lands in the attacker’s polling process. No service principal, no consent event, no app registration to find later. Just a refresh token issued to a Microsoft-published client ID that you cannot block without breaking half your tooling.

What the detection looks like

The primary signal for the consent variant lives in AuditLogs (the AAD audit stream, sourcetype azure:aad:audit in Splunk parlance, or the AuditLogs table in Sentinel). The event you want is:

OperationName = "Consent to application"
Category = "ApplicationManagement"
Result = "success"

The interesting fields are TargetResources (which contains the app display name, app ID, and the scopes granted under modifiedProperties), InitiatedBy.user.userPrincipalName, and the IP address. The scopes are buried in a JSON blob inside modifiedProperties[?].newValue and you have to parse them out — the structure is ConsentContext.IsAdminConsent, ConsentAction.Permissions, and a comma-delimited string of Scope=... entries. The Microsoft docs on this are wrong in at least one place as of the current docs revision; they describe ConsentType as a top-level field and it isn’t, it’s nested.

Volume baseline depends entirely on your consent policy. In a tenant of around 5,000 users where user consent is wide open (the default until you turn it off), expect 50 to 200 of these events per day, dominated by users authorizing Office add-ins, GitHub Copilot, Grammarly, Loom, and whatever the marketing team installed this week. In a tenant where user consent is restricted to verified publishers with low-impact permissions, the same population produces under five per day, and most of those are the admin consent workflow firing. The detection only works if you’ve done the consent policy work first — otherwise you’re looking for a needle in a stack of needles.

The rule that earns its keep:

  • OperationName == "Consent to application"
  • AND scope list contains any of Mail.Read, Mail.ReadWrite, Files.Read.All, Files.ReadWrite.All, Sites.Read.All, offline_access, full_access_as_user, Directory.Read.All
  • AND the app’s publisher is not in your allowlist of verified ISVs
  • AND ConsentContext.IsAdminConsent == false

That will fire on actual consent phishing and on the long tail of unsanctioned SaaS. Both are things you want to see. Treat the second category as a finding, not a false positive.

For the device code variant, the signal is in SignInLogs, and it’s noisier. The field is AuthenticationProtocol (or authProtocol depending on the export path — yes, the same field has two names depending on whether you pull it via diagnostic settings or the Graph API, which is its own gripe). You want:

AuthenticationProtocol == "deviceCode"
ResultType == 0

joined against the user’s normal sign-in pattern. Legitimate device code use exists — Azure CLI on a jump box, kubectl auth against AKS, some IoT provisioning flows — so a flat rule on deviceCode will drown you. The signal that matters is deviceCode from a user who has never used it before, against a client like Microsoft Azure PowerShell or Microsoft Graph Command Line Tools, from an IP that doesn’t match the user’s typical ASN. Hosted ASNs (DigitalOcean, Hetzner, Vultr, OVH) on a device code sign-in for a non-engineer user is the cleanest version of this signal.

The tuning fight

First week, the consent rule will fire mostly on Microsoft first-party apps and on a handful of ISVs the business genuinely uses. You will spend the week building the allowlist. Don’t allowlist by app display name — the display name is attacker-controlled in the multi-tenant app registration and ‘Microsoft Office’ is not a reserved string. Allowlist by AppId (the GUID) and by verified publisher ID. Yes, that means you have to maintain a GUID list. Yes, it’s annoying. The alternative is an attacker registering an app called Adobe Acrobat Sign and watching your name-based filter swallow the alert.

Second week, the noise shifts to the Power Platform connectors and to whatever low-code thing your finance team is doing. Power Automate flows generate consent events that look almost identical to the malicious shape — offline_access, User.Read, sometimes Mail.Send. The discriminator is the originating app ID (Power Automate has a known set) and the consent context. Carve those out explicitly. Don’t carve out Mail.Send globally; that’s a scope that should always alert.

The device code rule’s noise comes from engineers. Mostly az login against shared tenants, occasionally an kubectl flow that hits a public IP because the dev was on a coffee shop network. The fix is a watchlist of users who legitimately use device code flow — usually under 5% of the workforce — and an exclusion that only suppresses the alert when the source IP geolocates to a country the user has signed in from in the last 30 days. That second clause is what separates this from a useless rule.

False positives that will survive both rounds of tuning: ISV trials that procurement didn’t tell you about, contractor onboarding where the contractor consents to their own org’s apps from inside your tenant, and the occasional Microsoft service principal that gets re-provisioned with a new app ID after a backend change on Microsoft’s side. The last one is rare but maddening because the AppId changes and your allowlist breaks silently.

Remediation that actually clears the access

When the rule fires and it’s real, the runbook is:

  1. Disable the service principal in the victim tenant (Update-MgServicePrincipal -AccountEnabled:$false). This is faster than deleting it and preserves the audit trail.
  2. Revoke the refresh tokens for the affected user (Revoke-MgUserSignInSession). This kills user sessions but does not kill the service principal’s tokens — do both.
  3. Remove the consent grant itself (Remove-MgOauth2PermissionGrant for delegated permissions, Remove-MgServicePrincipalAppRoleAssignment for application permissions).
  4. Pull the Microsoft Graph activity logs for the service principal’s app ID for the window between consent and revocation. This is a separate data source from SignInLogs — it’s the MS Graph activity log, and most tenants don’t have it enabled by default. If you don’t have it on, turn it on now; the retention starts from when you enable it, not retroactively.
  5. Hunt for any data the app’s scopes would have allowed it to take. Mail.Read plus offline_access over a 48-hour window means assume the mailbox is exfiltrated.

Step four is where most teams miss the actual blast radius. The Entra audit log tells you what was consented to. The Graph activity log tells you what was done with that consent. Those are different sourcetypes, different retention budgets, and usually different ingestion paths. Budget for both.

Control mapping

The relevant 800-53 anchors are AC-2 for the service principal lifecycle (a service principal created by user consent is an account; treat it like one), AC-6 for the consent scope restriction itself, IA-5 for the authenticator binding question (which the consent flow sidesteps and that’s the whole point), AU-2 and AU-12 for ensuring both AuditLogs and MS Graph activity logs are flowing and retained, SI-4 for the detection content, and CM-7 for restricting the device code flow at the conditional access layer where you can. SR-3 belongs in the conversation too, because the verified-publisher allowlist is a supply chain decision and treating it as a security-team-only call is how shadow SaaS keeps winning.

The conditional access piece deserves its own note. You can block device code flow with a CA policy targeting Authentication flows -> Device code flow -> Block, and for most user populations you should. Exempt the engineer watchlist explicitly. The policy is in GA but the policy designer UI was buggy through at least the late-2025 refresh — verify the policy with What If before you trust it, because the ‘all users except group X’ targeting silently drops the exception in some configurations.

The shape of the problem

FIDO2 raised the cost of credential phishing. It did not raise the cost of consent phishing, because consent phishing doesn’t fight authentication — it rides on top of a successful authentication and asks for something the user is empowered to give. The fix is at the authorization layer, not the authentication layer, and it requires consent policy work, app governance work, and detection content in that order. Skip the policy work and the detection drowns. Skip the detection and the policy gives you false comfort. Both, or neither.