Home / Blog / Alert Triage
How to Triage a Suspicious OAuth App Consent Alert in Entra ID
Work out what a user consented to, whether the app has touched any data and how to remove it safely, with KQL for Entra audit, sign in and Graph activity logs.
Consent phishing does not steal a password. It persuades a user to grant a malicious app permission to read their mail or files, and the app then keeps that access through tokens, even after a password reset. This guide is for SOC analysts in government and the wider public sector who need to triage a consent alert quickly: what was granted, has it been used, and how do you remove it without losing evidence or breaking change control?
Scenario: At 10:42 your Sentinel rule for high risk consent fires. A policy officer in a central government department has consented to an app called "Secure PDF Viewer" that asked for Mail.Read, Files.Read.All and offline_access. The user says they clicked a link in an email about a consultation response and "just pressed Accept". The tenant uses custom consent settings agreed at the change board two years ago. You need to decide within the hour whether this is a nuisance app or an active intrusion into OFFICIAL mail.
Pull the consent event apart
Every successful consent writes a Consent to application record to the Entra ID audit log, usually alongside Add delegated permission grant and, if the app was new to your tenant, Add service principal. The useful detail sits inside TargetResources as modified properties, so you need to unpack it before you can read it.
AuditLogs
| where TimeGenerated > ago(7d)
| where OperationName == "Consent to application"
| where Result == "success"
| extend Actor = tostring(InitiatedBy.user.userPrincipalName),
ActorIP = tostring(InitiatedBy.user.ipAddress),
AppName = tostring(TargetResources[0].displayName),
ServicePrincipalId = tostring(TargetResources[0].id)
| mv-apply Prop = TargetResources[0].modifiedProperties on (
summarize Props = make_bag(bag_pack(tostring(Prop.displayName), tostring(Prop.newValue)))
)
| mv-apply Detail = AdditionalDetails on (
summarize AppId = take_anyif(tostring(Detail.value), tostring(Detail.key) == "AppId")
)
| extend IsAdminConsent = tostring(Props["ConsentContext.IsAdminConsent"]),
Permissions = tostring(Props["ConsentAction.Permissions"]),
Reason = tostring(Props["ConsentAction.Reason"])
| project TimeGenerated, Actor, ActorIP, AppName, AppId, ServicePrincipalId,
IsAdminConsent, OnBehalfOfAll, Permissions, Reason, CorrelationId
| order by TimeGenerated desc
Read the output field by field:
- IsAdminConsent and OnBehalfOfAll: if either is true, the grant covers every user in the tenant, not just the one who clicked. That changes the scale of the incident immediately.
- Permissions: the scopes appear after the
Scope:label inside the string. Note every one. - Reason: a value such as "Risky application detected" means Microsoft flagged the app. Treat it as supporting evidence, not proof either way.
- AppId and ServicePrincipalId: you need both for every query and containment step that follows.
Note: Values in the modified properties often arrive wrapped in extra quotation marks, for example "True" with quotes. Use has "True" rather than an exact equality match when you filter on them, or your rule will silently miss events.
Judge the scopes and the publisher
Not every consent is malicious. Users consent to scheduling tools, conference apps and browser extensions every week. What decides the priority is the combination of what the app can read and how long it can keep reading it.
Sign in only
The app learns who the user is and little else. Common for single sign on to legitimate services.
openid, profile, email, User.ReadPersonal data
Calendar, contacts and people data. Useful for reconnaissance and targeting further phishing at colleagues.
Calendars.Read, Contacts.Read, People.ReadMail and files
Read or send mail, change mailbox settings, read every file the user can reach. Paired with offline_access, the app keeps access through refresh tokens.
Mail.Read, Mail.Send, Files.Read.AllThen look at the app itself in the Entra admin centre under Enterprise applications. Questions that raise or lower suspicion:
- Is the publisher verified, and does the publisher domain match the organisation the app claims to be?
- Is it a multi tenant app registered in someone else's tenant, with no app registration in yours?
- Does the reply URL point at a domain that matches the app's stated purpose, or at a free hosting or recently registered domain?
- Is the name generic and urgent sounding, such as "Document Viewer", "Secure PDF" or "Mail Update"?
- Is this the first time anyone in the tenant has used this AppId?
Tip: Since mid 2025, tenants on the Microsoft managed default consent policy have seen Microsoft progressively block end user consent to broad Files, Sites, Mail and Calendar permissions for third party apps. If a user in your tenant could consent to Files.Read.All on their own, your tenant is probably on a custom or older policy. That is worth noting in the incident record, because it points to a control gap as well as a user decision.
Check who else consented and how the lure arrived
Consent phishing campaigns rarely target one person. The same app is often sent to a whole team or a distribution list, so scope by AppId before you focus on the first user.
let SuspectAppId = "a1b2c3d4-0000-4000-8000-00000000f00d";
AuditLogs
| where TimeGenerated > ago(30d)
| where OperationName == "Consent to application"
| mv-apply Detail = AdditionalDetails on (
summarize AppId = take_anyif(tostring(Detail.value), tostring(Detail.key) == "AppId")
)
| where AppId == SuspectAppId
| summarize Users = dcount(tostring(InitiatedBy.user.userPrincipalName)),
UserList = make_set(tostring(InitiatedBy.user.userPrincipalName), 50),
Results = make_set(Result),
FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated)
Include failures in this count. A failed consent means the policy stopped the user, but it still tells you who received and clicked the lure. Those users need the same awareness message and may be targeted again with a different app.
For the delivery side, take the time of the first consent and search email trace or Defender for Office 365 for messages to the affected users in the preceding hour that contain links to login.microsoftonline.com with the suspect client ID. That gives you the sender, subject and any other recipients who have not clicked yet.
Find out whether the app has used its access
This is the question that decides severity. A consent with no follow on activity is a contained near miss. A consent followed by Graph calls to read mail is a data incident.
Token activity in non interactive sign ins
When the app uses the user's refresh token to get new access tokens, Entra records a non interactive sign in under the app's ID. The IP address here is usually the attacker's infrastructure, not the user's.
let SuspectAppId = "a1b2c3d4-0000-4000-8000-00000000f00d";
AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(7d)
| where AppId == SuspectAppId
| summarize SignIns = count(), FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated),
IPs = make_set(IPAddress, 20), Resources = make_set(ResourceDisplayName, 10)
by UserPrincipalName, ResultType
| order by FirstSeen asc
Data access in Microsoft Graph activity logs
If you send Microsoft Graph activity logs to your workspace, you can see the actual API requests the app made. This is the closest you get to a list of what was read.
let SuspectAppId = "a1b2c3d4-0000-4000-8000-00000000f00d";
MicrosoftGraphActivityLogs
| where TimeGenerated > ago(7d)
| where AppId == SuspectAppId
| extend Endpoint = tostring(split(tostring(parse_url(RequestUri).Path), "/")[2])
| summarize Requests = count(), FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated),
IPs = make_set(IPAddress, 10), SampleUris = make_set(RequestUri, 15)
by UserId, RequestMethod, Endpoint, ResponseStatusCode
| order by Requests desc
Requests to /me/messages, /me/mailFolders or /me/drive with successful status codes mean data has left the tenant. A large number of requests in a short burst often indicates bulk download of a mailbox.
Watch out: Graph activity logs are not on by default. They need an Entra ID P1 or P2 licence and a diagnostic setting that sends them to Log Analytics, and they only cover requests made after you enabled them. Entra itself keeps audit logs for only 7 days without a premium licence and 30 days with one. If you cannot show activity, record that as "not visible" rather than "did not happen", and use the mailbox audit log, including MailItemsAccessed where licensed, as a second source.
Mail read by the app
HighSuccessful Graph requests to mail endpoints from an unfamiliar IP address after the consent time.
Open a security incident, contain now and start a data impact assessment with your information assurance and data protection teams.
Tenant wide grant
HighIsAdminConsent or OnBehalfOfAll is true, or the consenting account holds an application admin role.
Treat the admin account as compromised as well, and scope every user whose data the app could reach.
Consent with no use yet
MedA successful grant but no token refreshes or Graph calls from the app so far.
Contain promptly anyway. Attackers often wait hours before using a grant, and the refresh token is already theirs.
Contain the app without losing evidence
A password reset does nothing here, because the app holds its own tokens. Containment means stopping the app and removing what it was granted. In a public sector tenant, tenant wide setting changes normally go through the change board, but disabling a confirmed malicious app should already be covered by your incident process as an emergency change. Check that before the alert, not during it.
- L0CaptureExport the audit, sign in and Graph records, the app's permissions page and its service principal details to the case file.Analyst
- L1DisableTurn off sign in for the service principal so no new tokens are issued, while keeping the object for investigation.Incident lead
- L2RevokeRemove the delegated grants and any app role assignments, then revoke the affected users' sessions.Identity team
- L3HardenReview user consent settings and the admin consent workflow, approved through normal change control.Change board
Connect-MgGraph -Scopes "Application.ReadWrite.All", "DelegatedPermissionGrant.ReadWrite.All", "User.RevokeSessions.All"
$appId = "a1b2c3d4-0000-4000-8000-00000000f00d"
$sp = Get-MgServicePrincipal -Filter "appId eq '$appId'"
# Record the grants before removing them
$grants = Get-MgOauth2PermissionGrant -Filter "clientId eq '$($sp.Id)'" -All
$grants | Select-Object Id, ConsentType, PrincipalId, Scope | Export-Csv -Path ".\INC-2026-0412_grants.csv" -NoTypeInformation
# Stop new tokens being issued to the app
Update-MgServicePrincipal -ServicePrincipalId $sp.Id -AccountEnabled:$false
# Remove the delegated permission grants
$grants | ForEach-Object { Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $_.Id }
# Revoke refresh tokens for the affected user
Revoke-MgUserSignInSession -UserId "p.officer@department.example.gov.uk"
Access tokens the app already holds stay valid until they expire, by default somewhere between 60 and 90 minutes, so keep watching the Graph activity logs for that window after containment. If the app also had application permissions, check them with Get-MgServicePrincipalAppRoleAssignment and remove them too. Do not delete the service principal until the investigation is closed, because deleting it removes the object you are documenting.
Close the alert and report it
Write the outcome so someone outside the SOC can follow it. Include the app name, AppId, publisher, scopes granted, every user who consented or tried to, the evidence of use or the gap in visibility, and the containment times. Then decide where it goes next:
- Data accessed: involve your information assurance lead and data protection officer, and follow your department's route for reporting significant incidents, which for central government includes the NCSC.
- Malicious app confirmed: report it to Microsoft from the enterprise application's page so other tenants benefit.
- Control gap found: raise the consent policy change formally. It maps to CAF principle B2, identity and access control, and the detection work supports C1, security monitoring.
Note: Ask affected users to review the apps they have consented to at myapps.microsoft.com. It is quick, it often surfaces old forgotten grants, and it turns a single incident into a useful awareness moment for the team.
Key takeaways
- Consent phishing grants an app its own tokens, so password resets and MFA do not remove the attacker.
- Unpack
Consent to applicationevents to read IsAdminConsent, OnBehalfOfAll, the scopes and the AppId. - Prioritise by scope: mail and file access combined with offline_access is the high risk pattern.
- Scope by AppId across all users, including failed consents, before focusing on one person.
- Use non interactive sign ins and Graph activity logs to prove use, and record gaps honestly.
- Disable the service principal, remove the grants and revoke sessions, and keep the object as evidence.
Run the first query over the last 30 days this week, even without an alert. Every high risk grant you find, malicious or not, is either a quick removal or a documented exception, and both make the next real consent alert faster to triage.
Written by Freya, Senior SOC Analyst, Government