Everyone hardened the password. Consent phishing does not need one. The victim sees a real sign-in page served by their real identity provider, satisfies MFA legitimately, and the only unusual thing on screen is a permissions dialog they click through.
The flow, from the defender's side
- The attacker registers an application requesting delegated permissions - read mail, read files, and offline access.
- The victim follows a link to the provider's genuine authorization endpoint. Domain, certificate and MFA are all correct, because the user really is signing in to their own tenant.
- The consent screen lists the permissions. The user approves.
- The provider issues the application an authorization code, which the attacker's backend exchanges for an access token and a refresh token.
Step 4 is the whole problem. That grant is independent of the password. Resetting the password does not revoke it. Re-enrolling MFA does not revoke it. The attacker reads mail through the API, from their own infrastructure, and your interactive sign-in logs stay quiet after the initial consent.
The refresh scope is the payload
What converts a one-time click into persistence is the scope granting offline access, because that is what returns a refresh token. Combined with a broad mail or files read permission, it is a durable and silent mailbox tap. When triaging a consent event, read the scope string before anything else.
The audit events
In Entra ID the grant is recorded in the directory audit log. In Sentinel or Log Analytics:
AuditLogs
| where OperationName in ("Consent to application", "Add OAuth2PermissionGrant",
"Add service principal")
| extend actor = tostring(InitiatedBy.user.userPrincipalName)
| extend app = tostring(TargetResources[0].displayName)
| project TimeGenerated, OperationName, actor, app, Result
| order by TimeGenerated desc
The requested permissions travel in the modified-properties blob attached to the target resource. Parse them out, because the goal is to alert on interesting scopes rather than on every consent - a tenant of any size consents to something every day.
What makes one worth a look:
- an application the tenant has never seen before, consented to by several users within a short window. That is the shape of a campaign; one curious employee is not.
- a grant carrying mail, files or directory read permissions
- consent from an IP, ASN or country the user never uses
- an unverified publisher, or a reply URL on a domain unrelated to the app's display name
- an admin consent, which applies tenant-wide rather than to one mailbox
Google Workspace has the same story with different nouns: third-party app access with OAuth token events in the admin audit log, and an app allowlist that can be scoped by permission.
Prevention that does not break the business
- Turn off unrestricted user consent, and permit it only for verified publishers requesting low-impact permissions.
- Turn on the admin consent request workflow, so a blocked user has a path that ends with a reviewer instead of a stalled ticket. Consent restrictions without that workflow generate shadow IT.
- Inventory the grants you already have and prune them. Every tenant carries old consents nobody can explain.
Contain it in the right order
This is where response time is lost, because the instinct is wrong.
- Revoke the grant and remove the application's service principal from your tenant. That is containment. The password is not involved.
- Revoke the user's refresh tokens as well, since you do not yet know what else the session did.
- Then scope it: API-side mail read volume, new inbox rules, forwarding, file downloads, and whether the same application was consented by anyone else.
- Block the application id and keep it on a watchlist, then hunt the delivery message across all mailboxes.
A password reset on its own leaves the grant in place, so the app is back in as soon as the user signs in again. That single fact is why consent phishing keeps working.
