§ AU

Token theft after AitM phishing in Entra ID: what the SignInLogs actually show in 2026

Token protection, Continuous Access Evaluation, and device-bound refresh tokens were supposed to make AitM phishing against Entra ID a non-issue. They didn’t. They raised the floor — replaying a stolen cookie from a coffee-shop IP now usually trips something — but the kits adapted, the tenants that turned the features on tend to be the ones with the most exceptions carved out, and the SignInLogs got noisier without getting clearer. If you’re the one paged at 0247 because Defender XDR fired a Suspicious sign-in activity on a CFO account, the question is not whether token theft is possible. It’s which fields in which table actually tell you whether the session is the user or the attacker, and how fast you can kill it before the OAuth grant for offline_access walks out the door behind a consented third-party app.

This is the piece I wish someone had handed me the first time I had to write a runbook for it.

The shape of the problem

The modern kits — Tycoon 2/3, Mamba, the Rockstar2FA descendants, whatever the current Evilginx fork is called this quarter — don’t try to defeat FIDO2. They reverse-proxy the legitimate Entra login, harvest the resulting session cookie (ESTSAUTHPERSISTENT and friends), and replay it from attacker infrastructure. If the tenant has token protection enforced for sign-in tokens and the user authenticated from a compliant, Entra-joined device, the replay should fail because the cookie is bound to a key the attacker doesn’t hold. Should.

In practice, several things keep it working. The user signed in from a BYOD browser that didn’t get a device-bound session token in the first place. The conditional access policy that enforces token protection has an exclusion for the legacy ADFS-migration group that nobody has cleaned up since 2023. The app the user is hitting — usually some SaaS that federates via SAML, not OIDC — doesn’t participate in token protection at all, so the cookie was never bound. Or the attacker isn’t replaying the session cookie, they’re going straight for an illicit consent grant to a multi-tenant app with Mail.Read and offline_access, and the replayed session was only ever a means to that end.

The last one is the case most teams underweight. By the time the SOC sees the AitM alert, the attacker may not need the session anymore.

What’s actually in SignInLogs

If you’re pulling from SigninLogs in Sentinel (or the AAD connector into Splunk, where the field names mostly survive transit but a few get flattened), these are the columns that matter and the gotchas that come with them.

AuthenticationDetails is a nested array. The element you want is the one where authenticationStepResultDetail is MFA successfully completed and authenticationMethod is FIDO2 security key or Windows Hello for Business. If MFA was satisfied by Previously satisfied, you’re looking at a token-driven sign-in, and that’s exactly the case where AitM replay shows up — the attacker has the session, Entra sees a successful sign-in to a new app with no fresh MFA challenge, and the row looks boring.

TokenProtectionStatusDetails.signInSessionStatus is the field you actually care about, and it has three values that matter: bound, unbound, and notSupported. A bound sign-in followed minutes later by an unbound sign-in from the same user, same app, different ASN — that’s the shape. Note that notSupported is not the same as failure; it means the client or resource didn’t negotiate binding at all. Treat notSupported sign-ins as the carve-out population, not the suspicious one, or your detection will drown.

NetworkLocationDetails, IPAddress, and the often-overlooked autonomousSystemNumber. ASN is the one to pivot on, not IP. Residential proxies and the commercial proxy networks the kits rent rotate IPs by the hour but tend to cluster in a manageable set of ASNs. A sign-in for a Seattle-based user from AS14061 (DigitalOcean) at 0312 local with ClientAppUsed = Browser and UserAgent matching the user’s normal Chrome string — that’s not the user.

SessionLifetimePolicies will tell you whether CAE is actually engaged for this token. If it isn’t (and for a non-trivial fraction of Microsoft Graph calls from third-party apps, it isn’t), revoking the session in the portal does not do what you think it does. The token keeps working until it expires on its own clock.

The detection and its first-week problem

The naive detection writes itself: same UserId, two SigninLogs rows within 15 minutes, different autonomousSystemNumber, second row has TokenProtectionStatusDetails.signInSessionStatus == "unbound" and the first has "bound". Set the lookback to 15 minutes, the impossible-travel threshold to 800 km, and ship it.

Week one, expect this to fire somewhere in the range of dozens per day per thousand seats, and almost none of it will be malicious. Where does the noise come from?

VPN egress changes. The user is on the corporate VPN that egresses through one ASN, they pop off VPN to join a Teams call (because the VPN client mangles UDP, the same way it has for six years across three vendors), and now they’re on their home ISP. Two ASNs, ten minutes apart, perfectly legitimate.

Mobile sign-ins. The Outlook iOS app and the Authenticator app talk to Entra from carrier ASNs that look nothing like the user’s office, and the token binding story on mobile is messier than on desktop. ClientAppUsed = Mobile Apps and Desktop clients plus DeviceDetail.operatingSystem = iOS is your carve-out, but be careful — the kits know this and some of them spoof the UA accordingly. Pair it with DeviceDetail.trustType = Azure AD registered to tighten it.

Microsoft’s own infrastructure. Service-principal sign-ins, B2B guest flows where the home tenant lives on a different ASN, and the long tail of Cross-tenant access rows that look like impossible travel because they literally are — the user signed into a partner tenant from a partner network. Filter CrossTenantAccessType != "none" out of the base detection or rewrite the join to scope to the home tenant.

After that round of tuning you’ll have something that fires once or twice a day per thousand seats and is worth a tier-2 look. Push it lower than that and you’re suppressing real cases; the AitM signal is not loud, and the kits are deliberately quiet on the second hop.

One more thing the first detection always misses: the consent grant. Add a join against AuditLogs where OperationName == "Consent to application" and InitiatedBy.user.id matches the suspect sign-in’s UserId, within an hour either side. That’s the actual badness — the session was the vehicle, the grant is the payload, and the grant survives every session revocation you can do from the portal.

Artifacts that aren’t in Entra

SignInLogs will tell you something happened. They will not tell you what the attacker did with the session. For that you need:

The Unified Audit Log in Purview, specifically MailItemsAccessed, Send, New-InboxRule, and Update-InboxRule. The inbox-rule one is the canonical post-compromise artifact and has been since 2019; the rules created by these kits tend to be uncreative (forward-to-external, delete-from-sent, filter on payroll|wire|invoice) and the Purview events have the rule body in the Parameters field. Pull it. The rule will still be there even if the attacker deleted it from the user’s view, because OWA’s rule-delete is a soft delete and Purview logs the create.

OfficeActivity (Sentinel) or the M365 connector equivalent, filtered to Operation in ("FileDownloaded", "FileSyncDownloadedFull") for the suspect UserId. If the attacker hit OneDrive or SharePoint, this is where the bulk-download signature shows up — usually as a burst of FileDownloaded events at machine cadence (sub-100ms between events), which the user-agent string sometimes gives away as a non-browser client.

Graph API call logs from MicrosoftGraphActivityLogs, which a lot of tenants still haven’t enabled because the volume is genuinely large and the retention math is ugly. Turn it on for at least the privileged users; without it, you cannot reconstruct what a stolen token actually read. The field to pivot on is AppId joined against the consented-app list.

Containment when CAE is on

The runbook most teams write says “revoke sessions, reset password, require MFA re-registration.” That order is wrong if a consent grant has happened. Do this instead:

Revoke the OAuth2 permission grant first (Remove-MgOauth2PermissionGrant or the equivalent Graph call), then disable the service principal of the consented app if it’s third-party and shouldn’t be in the tenant. Then revoke sessions. Then reset credentials. If you reset credentials first, the attacker’s refresh token tied to the consented app keeps working — it’s not bound to the user’s password — and you’ve just told them you’re on to them.

On CAE: revoke-sessions propagates within a couple of minutes for CAE-aware resources (Exchange Online, SharePoint, Graph for the most part). For non-CAE resources, the access token lives until its exp, which is typically an hour but can be longer for certain app registrations. Plan for that hour. The Graph API call to force token revocation is revokeSignInSessions, and yes, it has to be called per user — there’s no fleet version, which is the kind of API design choice that makes incident response take longer than it should.

Mapping

Activity 800-53
SignInLogs / AuditLogs / Graph activity ingestion and retention AU-2, AU-6, AU-12
Token protection, CAE, conditional access enforcement AC-2, AC-7, AC-12, IA-2(1), IA-2(2)
Consent grant review, app governance AC-6, CM-7, SA-9
AitM detection content, tuning, response playbooks IR-4, IR-5, SI-4
Risk-based sign-in and user risk scoring inputs RA-3, RA-5

None of these controls do the work on their own. The control that actually fails in most of the incidents I’d put money on is AC-6 — overbroad consent left enabled tenant-wide because turning it off broke the Slack integration two years ago and nobody wanted to own the fix.

What to take away

If your AitM detection is built only on impossible travel, rewrite it around TokenProtectionStatusDetails and ASN, and join it to consent-grant events. If your IR playbook for token theft starts with a password reset, reorder it. And if you’ve turned on token protection but haven’t audited which apps and which users are actually in scope, you have the dashboard, not the control. Defender will keep telling you the tenant is healthy. The CFO’s mailbox will keep being read.