TL;DR for defenders. An attacker registers an OAuth app and phishes a user into consenting to delegated permissions like Mail.Read and offline_access. No password is stolen and MFA is never challenged - the resulting refresh token is a legitimate, durable key to the user's mailbox and files over Microsoft Graph. Watch Entra audit logs for Consent to application events granting sensitive scopes, and the oauth2PermissionGrants POST in Graph activity. Then shrink the blast radius by turning off unrestricted user consent. Queries below.

Why a click beats a credential

Most phishing aims at credentials, so most defenses are built around the login: MFA, conditional access, impossible-travel rules. Consent phishing routes around all of it. The attacker doesn't want the user's password - they want the user to authorize an application. The OAuth 2.0 authorization-code flow is designed to let a third-party app act on a user's behalf, so when the victim clicks Accept on a consent screen, everything that follows is, technically, working exactly as intended.

The payoff is an access token plus, if offline_access was requested, a refresh token - a long-lived credential the app can keep redeeming for new access tokens without the user present. Because the app authenticates as itself and the consent already happened, the attacker's subsequent Graph API calls don't trigger an interactive sign-in or an MFA prompt. Revoking the user's password does nothing; you have to revoke the grant.

This isn't a lab curiosity. In January 2024 Microsoft disclosed that Midnight Blizzard (APT29 / NOBELIUM) password-sprayed into a legacy test account, found a dormant OAuth application with elevated access, then created additional malicious OAuth apps and granted them the Office 365 Exchange Online full_access_as_app role to read corporate mailboxes - all routed through residential proxies to blend in. Same primitive, nation-state hands.

How the attack actually works

Strip it down and consent phishing is four steps:

  1. Register an app. The attacker creates an app registration - in their own tenant, or a compromised one - and requests delegated Microsoft Graph permissions. The app's display name and publisher are often spoofed to look like something benign ("Annual Report Viewer", "0365 Secure Mail").
  2. Send the consent link. The victim receives a normal-looking link that points at a legitimate Microsoft endpoint (login.microsoftonline.com/.../oauth2/v2.0/authorize) with the attacker's client_id and a scope list. The domain is real, the TLS is valid, the page is Microsoft's - which is exactly why it's convincing.
  3. The user consents. If tenant settings allow user consent, clicking Accept writes an oauth2PermissionGrant and the app gains the requested scopes. If consent is restricted, the request is routed to an admin instead - which is the control we want.
  4. The app collects tokens and reads data. The app exchanges the authorization code for access and refresh tokens and starts calling Graph - GET /me/messages, /me/drive, and so on - from infrastructure that has nothing to do with the user.

The scopes that should make you nervous

Not every consent is hostile - users legitimately authorize apps all day. The risk lives in which permissions were granted. Delegated scopes worth alerting on include:

What you need flowing into your SIEM

The detections below assume you're shipping Entra ID logs into Microsoft Sentinel (or any Log Analytics workspace). Make sure these are actually enabled and exported - the data is useless if it's only living in the portal's 30-day view:

Detection strategy

Three layers, cheapest signal first. Treat the KQL as a starting point: the exact property paths inside modifiedProperties shift between schema versions, so validate field names against your own tenant before you alert on them.

1. Consent grants that include sensitive scopes

The bread-and-butter detection: a successful Consent to application where the granted permissions touch mail, files, or directory data. Joining the consenting user and the IsAdminConsent flag tells you immediately how bad it could be.

// Entra ID audit: consent to an app requesting sensitive delegated scopes
AuditLogs
| where OperationName == "Consent to application"
| where Result == "success"
| mv-expand TargetResources
| extend mods = TargetResources.modifiedProperties
| mv-expand mods
| extend propName = tostring(mods.displayName), newVal = tostring(mods.newValue)
| summarize props = make_bag(bag_pack(propName, newVal))
        by CorrelationId, TimeGenerated,
           Actor = tostring(InitiatedBy.user.userPrincipalName)
| extend Permissions   = tostring(props["ConsentAction.Permissions"]),
         IsAdminConsent = tostring(props["ConsentContext.IsAdminConsent"]),
         AppDisplayName = tostring(props["TargetId.ServicePrincipalNames"])
| where Permissions has_any (
        "Mail.Read", "Mail.ReadWrite", "Mail.Send", "MailboxSettings.ReadWrite",
        "Files.ReadWrite.All", "Sites.ReadWrite.All", "offline_access",
        "User.Read.All", "Directory.Read.All")
| project TimeGenerated, Actor, AppDisplayName, IsAdminConsent, Permissions

Tuning. Maintain an allowlist of AppIds for sanctioned applications and exclude them. IsAdminConsent == "True" is the high-severity branch - a tenant-wide grant exposes every user, not just the clicker.

2. The grant itself, in Graph activity (high fidelity)

The single highest-fidelity moment is the API write that creates the grant. A POST to a URI containing oauth2PermissionGrants is the act of granting delegated permission. When it's initiated by a non-privileged user - or from a residential-proxy IP, à la Midnight Blizzard - it deserves a hard look.

// The moment a delegated permission grant is created via Microsoft Graph
MicrosoftGraphActivityLogs
| where RequestMethod == "POST"
| where RequestUri has "oauth2PermissionGrants"
| where ResponseStatusCode in (200, 201)
| project TimeGenerated, UserId, AppId, IPAddress, RequestUri, ResponseStatusCode
// Enrich UserId against your privileged-role list; grants from standard users
// - or from hosting / proxy ASNs - are the ones to surface.

Tuning. Your own admins legitimately create grants during app onboarding. Scope this to actors outside your identity/app-admin roles, or correlate the source IP against known administrative networks.

3. Application (app-only) permission grants

The Midnight Blizzard variant didn't stop at delegated consent - it assigned an app application permissions, which grant standing access that outlives any user session. New app-role assignments should be change-controlled, so alert on them and reconcile against a ticket.

// App-role (application permission) assignments - standing, user-independent access
AuditLogs
| where OperationName in ("Add app role assignment to service principal",
                          "Add app role assignment grant to user")
| where Result == "success"
| mv-expand TargetResources
| extend mods = TargetResources.modifiedProperties
| mv-expand mods
| extend propName = tostring(mods.displayName), newVal = tostring(mods.newValue)
| where propName in ("AppRole.Value", "ServicePrincipal.DisplayName")
| project TimeGenerated, OperationName,
          Actor = tostring(InitiatedBy.user.userPrincipalName), propName, newVal

Tuning. High-value app roles to flag by name include full_access_as_app (Exchange), Mail.ReadWrite, and any *.ReadWrite.All. These should be rare and deliberate.

Mitigation: shrink the consent surface

Detection tells you it happened; configuration stops most of it from happening at all. In order of impact:

Honest limitations

None of this is a silver bullet, and pretending otherwise would be the opposite of useful:

References

Scope note. This is a defensive writeup. It explains consent phishing only to the depth needed to detect and mitigate it, and deliberately ships no app-registration templates, phishing kits, or token-abuse tooling. UMBRASEC publishes defense, not offense.