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:
- 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").
- 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'sclient_idand ascopelist. The domain is real, the TLS is valid, the page is Microsoft's - which is exactly why it's convincing. - The user consents. If tenant settings allow user consent, clicking Accept
writes an
oauth2PermissionGrantand the app gains the requested scopes. If consent is restricted, the request is routed to an admin instead - which is the control we want. - 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:
Mail.Read,Mail.ReadWrite,Mail.Send- full mailbox read, and the ability to send as the user (useful for internal spear-phishing).MailboxSettings.ReadWrite- lets an attacker create hidden inbox rules to auto-forward or auto-delete, a classic persistence trick.Files.ReadWrite.All,Sites.ReadWrite.All- OneDrive and SharePoint content.offline_access- the one that turns a momentary click into durable access by issuing a refresh token.- Anything
.Allor directory-wide (User.Read.All,Directory.Read.All) - reconnaissance reach far beyond the consenting user.
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:
- Entra ID audit logs (
AuditLogstable) - records theConsent to applicationand permission-grant operations. This is the primary source. - Microsoft Graph activity logs (
MicrosoftGraphActivityLogs) - per-request telemetry for Graph calls, including the write that creates the grant and the reads that follow it. - Service principal sign-in logs (
AADServicePrincipalSignInLogs) - shows the application itself authenticating, often from infrastructure unrelated to any of your users. - If you're licensed for it, mirror the Unified Audit Log in Microsoft Purview, where the same consent events surface for compliance search.
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:
- Restrict user consent. Under Identity > Applications > Enterprise apps > Consent and permissions > User consent settings, move off "allow user consent for all apps." The recommended posture is to allow consent only for apps from verified publishers, for a defined set of low-risk permissions - everything else escalates.
- Turn on the admin consent workflow. When a user hits an app that needs more than they can grant, route an approval request to a reviewer instead of dead-ending them. This keeps productivity while putting a human between the click and the grant.
- Let Entra step up risky consent. Entra ID can detect a risky user-consent request and require admin approval automatically - keep that protection on.
- Review existing grants. The attacker you're worried about may already be in. Audit
enterprise applications and their
oauth2PermissionGrantsregularly, and revoke anything unrecognized - refresh tokens persist until you do. - Filter the lure. Defender for Office 365 can catch the consent-phishing email before the link is ever clicked; it's the cheapest layer when it works.
Honest limitations
None of this is a silver bullet, and pretending otherwise would be the opposite of useful:
- Scope strings are noisy. Plenty of legitimate apps request
Mail.Read. These detections surface candidates for triage, not confirmed incidents - the verdict comes from who consented, to what app, and what the app did next. - Schema drift is real. The shape of
modifiedPropertieshas changed over time. A query that works today can silently return nothing after a platform update, so test for regressions. - App-only grants need privilege. The application-permission path generally requires an already-privileged actor, so detection #3 is as much an insider/compromised-admin signal as a phishing one.
- This is Microsoft-shaped. The same consent-phishing pattern exists against Google Workspace and other OAuth providers; the artifacts and queries here are specific to Entra ID.
References
- MITRE ATT&CK - T1528: Steal Application Access Token
- Microsoft Entra - Protect against consent phishing
- Microsoft Entra - Configure how users consent to applications
- Microsoft - App consent grant investigation playbook
- Microsoft Defender for Office 365 - Detect and remediate illicit consent grants
- Microsoft Security Blog - Midnight Blizzard: Guidance for responders (Jan 2024)
- Thomas Naunheim - Detection and mitigation of illicit consent grant attacks in Azure AD
- Practical 365 - Investigating OAuth app abuse with the Graph Activity Log
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.