Skip to content

How to investigate consent phishing (illicit consent grants) in Entra ID

Consent phishing tricks a user into granting an attacker’s app access to their data. The app keeps that access through its permission grant, so resetting the password or enforcing MFA does not remove it. To investigate, find the consent event, scope what the app could reach, then remove the grants and the service principal.

Microsoft describes the attack like this:

the attacker creates a registered application in Microsoft Entra ID that requests access to data such as contact information, email, or documents. The attacker then tricks an end user into granting that application consent … After the illicit application is granted consent, it has account-level access to data without the need for an organizational account. Normal remediation steps (for example, resetting passwords or requiring multifactor authentication (MFA)) aren’t effective against this type of attack

The user signs in on a real Microsoft page and clicks Accept on a real consent prompt. Nothing about the sign-in looks wrong. The attacker never needs the password.

Why don’t a password reset and MFA fix it?

Section titled “Why don’t a password reset and MFA fix it?”

Microsoft states the result: those steps “aren’t effective against this type of attack”.

Our reading of why: the attacker’s foothold is not the user’s credentials. It is the consent grant, a record in your tenant that says “this app may act with these permissions”. A password reset changes the credential. MFA protects the sign-in. Neither one deletes the grant. As long as the grant and the app’s service principal exist in your tenant, the app is still allowed to ask for tokens.

So the fix is to remove what the consent created. Revoking the user’s sessions helps, but it is not the fix on its own (see remediation below).

Section titled “Which sign-in error codes point to consent activity?”

Three codes come from the consent step itself. All descriptions are Microsoft’s.

Code Microsoft’s description What it tells you
65001 “DelegationDoesNotExist - The user or administrator hasn’t consented to use the application with ID X. Send an interactive authorization request for this user and resource.” The app asked for a token for permissions nobody has granted yet. This is the state the attacker needs to get past.
90094 “AdminConsentRequired - Administrator consent is required.” A user tried to consent, and your policy reserves that consent for admins. The restriction held.
90095 “AdminConsentRequiredRequestAccess- In the Admin Consent Workflow experience, an interrupt that appears when the user is told they need to ask the admin for consent.” Same as 90094, but your tenant has the admin consent workflow on, so the user can send a request to a reviewer.

A related code is 65004, “UserDeclinedConsent - User declined to consent to access the app.” That is a user who saw the prompt and said no.

How to read them in an investigation (our reasoning, not Microsoft detections):

  • Several users hitting 90094 or 90095 for the same unfamiliar app in a short window looks like a campaign: someone sent a link to many people. Your consent settings stopped it. Find the delivery message.
  • An unfamiliar app that moves from 65001 to successful sign-ins means someone granted consent. Go to the audit log.
  • No consent codes at all does not clear you. If user consent is allowed for the permissions the app asked for, the user consents in one step and you may see only a success.

Apps that triggered consent-related codes in the last 14 days, with how many users hit each one:

let consentCodes = dynamic(["65001", "65004", "90094", "90095"]);
union
(SigninLogs
| project TimeGenerated, ResultType, AppId, AppDisplayName, UserPrincipalName, IPAddress),
(AADNonInteractiveUserSignInLogs
| project TimeGenerated, ResultType, AppId, AppDisplayName, UserPrincipalName, IPAddress)
| where TimeGenerated > ago(14d)
| where ResultType in (consentCodes)
| summarize Users = dcount(UserPrincipalName),
UserSample = make_set(UserPrincipalName, 10),
Codes = make_set(ResultType),
IPs = make_set(IPAddress, 10),
FirstSeen = min(TimeGenerated),
LastSeen = max(TimeGenerated)
by AppId, AppDisplayName
| sort by Users desc

ResultType is a string column, so the list holds "65001", not 65001. The query projects the same scalar columns from both tables before the union, so the type differences between the two tables don’t matter here.

Then check whether any of those apps later had successful sign-ins. The 65001 page has a query that finds apps that went from 65001 to success.

Section titled “Where is the consent recorded in the audit log?”

The consent itself is an audit event, not a sign-in. There are two places to look, and they are not the same log with the same field names.

Microsoft Purview (unified) audit log. This is what Microsoft’s illicit-consent guidance uses. Search for the activity “Consent to application” and check the IsAdminConsent value on each result. In Purview, IsAdminConsent is a field on the audit record.

Microsoft Entra audit log. Entra records the same operation, also named “Consent to application”. In Log Analytics or Sentinel that is the AuditLogs table. Here the admin-consent flag is not a top-level field. It sits inside the target’s modified properties, under a property named ConsentContext.IsAdminConsent, next to ConsentAction.Permissions for the granted permissions. lab check pending: those two property names come from review knowledge of Entra audit events, not from a Microsoft page in our sources.

Why IsAdminConsent matters (our reasoning): admin consent applies the grant for the whole tenant, while user consent applies to one user. An admin consent for an unknown app widens the scope of your investigation to every user’s data the permissions cover.

AuditLogs
| where TimeGenerated > ago(30d)
| where OperationName == "Consent to application"
| extend Actor = tostring(InitiatedBy.user.userPrincipalName),
ActorIP = tostring(InitiatedBy.user.ipAddress),
AppName = tostring(TargetResources[0].displayName),
ServicePrincipalObjectId = tostring(TargetResources[0].id)
| mv-apply Prop = TargetResources[0].modifiedProperties on (
summarize IsAdminConsent = take_anyif(tostring(Prop.newValue), tostring(Prop.displayName) == "ConsentContext.IsAdminConsent"),
Permissions = take_anyif(tostring(Prop.newValue), tostring(Prop.displayName) == "ConsentAction.Permissions")
)
| project TimeGenerated, Actor, ActorIP, AppName, ServicePrincipalObjectId, IsAdminConsent, Permissions, Result
| sort by TimeGenerated desc

Field paths to check against a real event before you alert on this query (Lab check pending):

  • InitiatedBy.user.userPrincipalName and InitiatedBy.user.ipAddress: the consenting user. An admin consent made through a portal may look different.
  • TargetResources[0]: assumed to be the app’s service principal. Check that id is the service principal object ID and not the app ID.
  • modifiedProperties newValue: assumed to be a string. The value may carry extra quotes (for example "\"True\""), so test with has "True" before you filter with ==.

You need the app’s application (client) ID. Get it from the app’s page under Enterprise applications, or with Get-MgServicePrincipal (see the next section). Then look at its sign-ins.

Delegated permissions act as the user, so those token requests show up as user sign-ins, mostly non-interactive ones. Microsoft lists “A client app uses an OAuth 2.0 refresh token to get an access token” as a non-interactive sign-in. Application permissions act as the app itself, which belongs in AADServicePrincipalSignInLogs.

let appId = "<application-id>";
union
(SigninLogs
| extend Country = tostring(LocationDetails.countryOrRegion), Log = "Interactive"),
(AADNonInteractiveUserSignInLogs
| extend Country = tostring(parse_json(LocationDetails).countryOrRegion), Log = "NonInteractive"),
(AADServicePrincipalSignInLogs
| extend Country = tostring(parse_json(LocationDetails).countryOrRegion), Log = "ServicePrincipal")
| where TimeGenerated > ago(30d)
| where AppId == appId
| summarize Rows = count(),
Users = make_set(UserPrincipalName, 20),
Resources = make_set(ResourceDisplayName, 10),
Results = make_set(ResultType, 10),
FirstSeen = min(TimeGenerated),
LastSeen = max(TimeGenerated)
by Log, IPAddress, Country
| sort by FirstSeen asc

Notes on this query:

  • LocationDetails is a string in AADNonInteractiveUserSignInLogs and dynamic in SigninLogs, so each leg extracts Country before the tables are combined. The service principal leg uses parse_json as well, which works whether that column is stored as a string or as dynamic.
  • UserPrincipalName is empty on service principal rows. That is expected: there is no user.
  • Non-interactive rows are aggregated when application, user, IP address, status and resource match. Rows counts log rows, not token requests.
  • ResourceDisplayName shows what the app asked for tokens to, for example Microsoft Graph or Office 365 Exchange Online. It doesn’t show which mailbox items or files were read. For that you need resource-side logs, such as the Purview audit log for mailbox and file access, or Microsoft Graph activity logs if you collect them.

Work in this order. Each step is a separate thing, and the order keeps evidence until you have recorded it.

1. Record the grants before you change anything. Run in PowerShell with the Microsoft Graph module:

Terminal window
Connect-MgGraph -Scopes "Application.ReadWrite.All","DelegatedPermissionGrant.ReadWrite.All","AppRoleAssignment.ReadWrite.All"
$sp = Get-MgServicePrincipal -Filter "appId eq '<application-id>'"
$sp | Select-Object Id, AppId, DisplayName, AppOwnerOrganizationId
# Delegated permissions (consent on behalf of users)
Get-MgOauth2PermissionGrant -Filter "clientId eq '$($sp.Id)'" |
Select-Object Id, ConsentType, PrincipalId, ResourceId, Scope
# Application permissions (app roles granted to the app)
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id |
Select-Object Id, AppRoleId, ResourceDisplayName

ConsentType AllPrincipals means a tenant-wide grant. Principal means a grant for the one user in PrincipalId. AppOwnerOrganizationId is the tenant the app was registered in; if it is not yours, the app is a third-party (multi-tenant) app.

2. Stop new tokens for the app. Disable the service principal so it can’t sign in while you work:

Terminal window
Update-MgServicePrincipal -ServicePrincipalId $sp.Id -AccountEnabled:$false

3. Remove the grants.

Terminal window
Get-MgOauth2PermissionGrant -Filter "clientId eq '$($sp.Id)'" |
ForEach-Object { Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $_.Id }
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id |
ForEach-Object { Remove-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id -AppRoleAssignmentId $_.Id }

4. Revoke the sessions of every user who consented. Revoke-MgUserSignInSession -UserId <id> “Invalidates all the refresh tokens issued to applications for a user (and session cookies in a user’s browser)” (Microsoft Graph documentation), and Microsoft’s revoke-access procedure says that after its steps “the user can’t gain new tokens for any application tied to Microsoft Entra ID.” The refresh tokens the attacker’s app holds for that user are refresh tokens issued to an application for that user, so this call covers them. The session revocation guide covers how to prove it worked. Access tokens already issued keep working until they expire; Microsoft gives a default lifetime of one hour.

5. Remove the service principal. Once the grants are recorded and gone:

Terminal window
Remove-MgServicePrincipal -ServicePrincipalId $sp.Id

Removing the service principal deletes your tenant’s copy of the app. It does not touch the app registration in the attacker’s tenant. That stays theirs.

If AppOwnerOrganizationId is your own tenant ID, the app was registered in your tenant, usually by a compromised account. Then there is also an application object to remove, and an actor to investigate:

Terminal window
$app = Get-MgApplication -Filter "appId eq '<application-id>'"
$app | Select-Object Id, DisplayName, CreatedDateTime, SignInAudience
Remove-MgApplication -ApplicationId $app.Id

Check AuditLogs for the Add application and Add service principal events for that app ID to see which account created it and from where, and treat that account as compromised.

700016 is expected. Microsoft’s description: “UnauthorizedClient_DoesNotMatchRequest - The application wasn’t found in the directory/tenant. This can happen if the application has not been installed by the administrator of the tenant or consented to by any user in the tenant.” After step 5 that is literally true for the attacker’s app. If their tooling keeps trying your tenant, our reasoning is that its requests fail with 700016. Each row gives you an IP and a time. Two neighbouring codes exist in Microsoft’s reference table and may show instead: 700011 “UnauthorizedClientAppNotFoundInOrgIdTenant - Application with identifier {appIdentifier} was not found in the directory. A client application requested a token from your tenant, but the client app doesn’t exist in your tenant, so the call failed.” for a removed app, and 7000112 “UnauthorizedClientApplicationDisabled - The application is disabled.” while the service principal is disabled between steps 2 and 5. lab check pending: Microsoft doesn’t describe the rows that follow a removal; confirm which of 700016 / 700011 a removed service principal returns, and that a disabled one returns 7000112, and which table the rows land in.

Watch for the app coming back. If users can still consent to that app, one more click recreates the service principal and a new grant. Check for a new “Consent to application” event for the same app name over the following days, and fix your consent settings (next section) before you close the incident.

Two settings carry most of the weight. Both are under Entra admin center > Enterprise applications (lab check pending: check current menu names).

  • User consent settings. Restrict what users may consent to on their own. With user consent restricted, a user who meets a consent prompt for a risky app gets 90094 instead of granting access.
  • Admin consent workflow. Let users request consent instead of granting it. The user gets 90095 and a request goes to a reviewer. The review queue becomes your consent-phishing triage queue: check the publisher, the permissions requested, and whether anyone in the business asked for the app.

Then keep watching. The two hunting queries above work as scheduled detections: a burst of 90094/90095 for one app, and any “Consent to application” event where the admin-consent flag is true.

  1. Find the app: consent codes in sign-in logs, or a user report of a strange “permissions requested” prompt.
  2. Find the consent: “Consent to application” in the Purview audit log (IsAdminConsent) or in Entra AuditLogs (ConsentContext.IsAdminConsent in modified properties).
  3. Note who consented, when, from which IP, for which permissions, and whether it was admin consent.
  4. Get the app ID and run the post-consent sign-in query. Note IPs, resources and time range.
  5. Check resource-side logs for what was read or sent in that time range.
  6. Record the grants (Get-MgOauth2PermissionGrant, Get-MgServicePrincipalAppRoleAssignment).
  7. Disable the service principal, remove the grants, revoke the consenting users’ sessions, remove the service principal. If the app was registered in your tenant, remove the application object too and investigate the account that created it.
  8. Watch for 700016 (or 700011 / 7000112) from the attacker’s tooling and for any new consent to the same app.
  9. Restrict user consent and turn on the admin consent workflow.

Sources

  1. Detect and remediate illicit consent grants
  2. Microsoft Entra authentication and authorization error codes
  3. Revoke user access in Microsoft Entra ID
  4. Microsoft Graph: user: revokeSignInSessions
  5. Non-interactive user sign-ins
  6. SigninLogs table reference (Azure Monitor)
  7. AADNonInteractiveUserSignInLogs table reference (Azure Monitor)
  8. AADServicePrincipalSignInLogs table reference (Azure Monitor)