Skip to content
HackInvasionCybersecurity Knowledge Hub

The Consent Trap: Hunting Malicious OAuth App Grants in Microsoft 365

Glowing blue cloud icon connected to a ring of network nodes and locks, with a red malicious node injecting an attack from the right, symbolizing a malicious OAuth app consent grant

Case file TH-006.
Nobody installed anything. No malware hit the disk, no suspicious logon tripped an alert, and the user's MFA is intact. Yet for three weeks, every email that landed in Priya's inbox was quietly copied to an external server. The entry point was a single click on a link that looked like a DocuSign request. The prompt said "Invoice Portal wants to read your mail — Accept?" She accepted. That was the whole attack.

This is the illicit consent grant pattern, and it is one of the hardest intrusions to spot with traditional endpoint telemetry. The attacker never touches the workstation. They register an application in their own Entra tenant, dress it up with a trustworthy name and logo, and phish your users into granting it delegated permissions to your data — Mail.ReadWrite, Files.ReadWrite.All, offline_access. From then on, the app talks to the Microsoft Graph API with a perfectly valid OAuth token. No password, no MFA prompt, nothing for the firewall to see.

This hunt looks for exactly that moment of consent — and for the consents that should never have happened.

The hypothesis

If an attacker has phished users into granting OAuth consent to a malicious application, then the Entra audit log will show user-level (non-admin) consent grants to apps requesting high-privilege Graph permissions — especially mail and file scopes — from apps with unverified publishers, recent registrations, or consents clustered around a phishing lure.

Data you'll need

SourceWhat it gives you
AuditLogs (Sentinel, Azure AD connector)Every consent grant: OperationName == "Consent to application", the consenter, the app, and the scopes granted.
CloudAppEvents (Defender for Cloud Apps)What the app did after consent — Graph calls, mail reads, file downloads by the service principal.
IdentityInfoDepartment/title of the consenter — attackers love finance and executive assistants.
Splunk: index=ms365 (O365 add-on)Unified Audit Log: Workload="AzureActiveDirectory", Operation="Consent to application*".

Hunting with KQL

This query surfaces consent grants where the app asked for scopes an attacker would actually want. Consent type matters: AllPrincipals means an admin consented tenant-wide (higher blast radius), while Principal means a single user clicked accept (the classic phishing pattern).

// Hunt H-OAUTH-01: user consent grants to apps requesting attacker-grade scopes
let RiskyScopes = dynamic([
    "Mail.ReadWrite", "Mail.Send", "Files.ReadWrite.All", "Sites.FullControl.All",
    "Directory.ReadWrite.All", "RoleManagement.ReadWrite.Directory",
    "Application.ReadWrite.All", "DelegatedPermissionGrant.ReadWrite.All",
    "User.ReadWrite.All", "Calendars.ReadWrite", "offline_access"
]);
AuditLogs
| where TimeGenerated > ago(14d)
| where OperationName == "Consent to application"
| extend Consenter   = tostring(InitiatedBy.user.userPrincipalName),
         AppId      = tostring(TargetResources[0].id),
         AppName    = tostring(TargetResources[0].displayName),
         RawTargets = tostring(TargetResources)
| extend ConsentType = extract(@"ConsentType[^A-Za-z]*([A-Za-z]+)", 1, RawTargets),
         Scopes      = extract(@"ConsentAction\.Permissions[^A-Za-z]*([^\]]+)", 1, RawTargets)
| where Scopes has_any (RiskyScopes)
    or (ConsentType == "AllPrincipals" and Scopes has "Mail.")
| project TimeGenerated, Consenter, AppName, AppId, ConsentType, Scopes, CorrelationId
| order by TimeGenerated desc

What this does, in plain English: it pulls two weeks of Entra audit logs, keeps only consent-grant events, and reconstructs who consented, to which app, and which permissions were handed over. It then keeps the grants that match known-dangerous Graph scopes — or any tenant-wide admin consent touching mail. Sort newest-first, because in a live intrusion the freshest consent is usually the attacker's.

Walkthrough: how the query reads the evidence
  • OperationName == "Consent to application" is the exact audit footprint of an OAuth consent. No consent prompt accepted, no event.
  • InitiatedBy.user.userPrincipalName identifies the human who clicked Accept — the phishing victim.
  • TargetResources[0] holds the app's identity and a modifiedProperties bag containing ConsentType (user vs. tenant-wide) and the granted permission list.
  • The extract() calls pull those two values out of the JSON without brittle positional parsing.
  • Filtering on RiskyScopes is the triage shortcut: a calendar-sync app asking for Calendars.Read is noise; the same app asking for Mail.ReadWrite + offline_access is a case.

Example: what a true positive looks like

FieldValueWhy it matters
TimeGenerated2026-09-21 14:03:11 UTC40 minutes after a reported phishing email to the same user
Consenterpriya.nair@contoso.com (Finance)High-value mailbox, non-admin user consent (Principal)
AppName / AppId"Invoice Portal" / 3f9a…c21dApp registered 6 days ago, publisher unverified
ScopesMail.ReadWrite, Files.ReadWrite.All, offline_accessSilent, persistent mailbox + file access; no legitimate need for an "invoice" app

Hunting with Splunk

The equivalent hunt against the Microsoft 365 Unified Audit Log. This version leans on rarity: apps that almost nobody in the tenant has consented to are where the malicious grants hide.

index=ms365 Workload="AzureActiveDirectory" Operation="Consent to application*"
| eval risky=if(match(Parameters,"(?i)Mail\.ReadWrite|Mail\.Send|Files\.ReadWrite\.All|Directory\.ReadWrite\.All|RoleManagement\.ReadWrite\.Directory|Application\.ReadWrite\.All"),"yes","no")
| stats count as consents, values(UserId) as consenters, values(ClientIP) as src_ips,
        earliest(_time) as first_seen, latest(_time) as last_seen,
        values(risky) as touched_risky_scopes by ObjectId
| where consents <= 3 AND touched_risky_scopes="yes"
| sort - first_seen

What this does, in plain English: for every app object that received consent, it counts how many consent events exist and who granted them. Apps consented to three times or fewer that also requested attacker-grade scopes bubble to the top — that is the profile of a targeted phishing lure, not an enterprise rollout.

Example hit

ObjectId=3f9a…c21d  consents=1  consenters=priya.nair@contoso.com
src_ips=203.0.113.44  first_seen=2026-09-21 14:03:11  touched_risky_scopes=yes

One user, one consent, risky scopes, source IP in a consumer ISP range rather than the corporate egress — open the case.

Validating the hit

  1. Inspect the app in Entra. Check publisher verification, app registration age, homepage URL, and whether the consent was user-level or admin-level. A 6-day-old app with an unverified publisher and a lookalike name is damning.
  2. Interview the consenter (gently). Ask whether they remember the prompt and what link led them there. Correlate with phishing reports and the user's inbox around the consent timestamp.
  3. Follow the app's hands, not just the consent. In CloudAppEvents, filter on the service principal and look for Graph reads of mail and file downloads after the consent time. Consent without subsequent API abuse is still a finding, but consent with mailbox reads is an incident.
  4. Measure the blast radius. Search for other consent events to the same AppId — including AllPrincipals grants. One phished user is a compromise; a tenant-wide grant is a breach.

Tuning out false positives

  • Verified enterprise SaaS (Zoom, Salesforce, ServiceNow, DocuSign): verified publishers with admin-consented, tenant-wide grants are expected. Maintain an allowlist of known AppId values.
  • Admin-consented rollouts: a spike of consents to one app on one day usually means IT deployed something. Correlate with change tickets before opening a case.
  • Low-risk scopes: User.Read, openid, profile are the background radiation of modern SaaS — filter them out of the triage view, not out of the data.
  • Microsoft first-party apps: Teams, Outlook add-ins, and Power Platform connectors generate consent events constantly; exclude publisher Microsoft Corporation after one sanity check.

What to do next

  • Revoke the consent immediately — in Entra admin center under Enterprise Applications, or via Graph: delete the oAuth2PermissionGrant and disable the service principal so existing tokens die.
  • Contain the consenter's account: revoke sessions, rotate credentials, and review mailbox rules and forwarding the attacker may have planted.
  • Block the pattern: tighten the user-consent policy (e.g., allow only verified publishers or require admin consent workflow), and block the app's AppId tenant-wide.
  • Hunt the lure: find the phishing message that delivered the consent link and pull it from every inbox it reached — then search for other consent grants in the same time window.

The evidence is always in the audit log — the attacker just bets you never look at it. — Amit Vijayan

Latest


EmoticonEmoticon