Status as of 28 October 2026

Quick read · 3 min read (full article: 16 min read)

AiTM token theft beats MFA without breaking it: a reverse-proxy phishing site relays the real Entra ID sign-in, lets the user finish MFA, captures the session cookie and replays it from attacker infrastructure, so the sign-in logs look clean. The answer is a loop: hunt the replay, alert on it, then take the stolen session's value away with phishing-resistant authentication strengths, token protection and continuous access evaluation (CAE), and prove each control works.

  1. Ingest both sign-in streams. Export SignInLogs and NonInteractiveUserSignInLogs to Log Analytics. Replayed refresh tokens land in the non-interactive log, and the portal keeps sign-ins for only 30 days on P1 and P2.
  2. Hunt the replay. Look for one SessionId seen from two or more autonomous systems with an OS or browser change, across SigninLogs and AADNonInteractiveUserSignInLogs. Normalise DeviceDetail and LocationDetails first, because they are dynamic in one table and string in the other. An ASN change alone is noisy, so allow-list your VPN and ZTNA egress ASNs.
  3. Correlate. Check for the BEC hand-off (an inbox rule plus new security info or a newly registered device) and for the ID Protection Anomalous token and Attacker in the Middle detections.
  4. Alert. Wrap the hunt in a scheduled Sentinel analytics rule with Account and IP entity mapping, running every 30 minutes over a 6-hour query period.
  5. Require phishing-resistant MFA. A Conditional Access policy with the built-in Phishing-resistant MFA authentication strength (FIDO2/passkey, Windows Hello for Business or platform credential, multifactor certificate-based authentication) stops the proxy harvesting a usable session. Keep builtInControls empty and leave the policy in report-only for a full business week.
  6. Bind the token. Token protection for native apps on Windows, iOS/iPadOS and macOS rejects a replayed refresh token from another device. On Windows, target Exchange, SharePoint and Teams Services and limit client apps to Mobile apps and desktop clients.
  7. Revoke fast. CAE-capable resources act on session revocation in near real time, with up to 15 minutes of propagation latency, instead of waiting out a token lifetime.
  8. Verify. Work every reportOnlyFailure row to zero, prove a password-plus-push test account is blocked, and confirm Outlook desktop is challenged within minutes after Revoke-MgUserSignInSession.

Caveat: enforcement breaks things. Token protection blocks unsupported clients rather than skipping them (PowerShell modules accessing SharePoint, VS Code extensions, Office perpetual clients), browser-based support is still in preview, and the Apple device requirements carry a preview label, so treat Apple rollout as a pilot. Authentication strength strands legacy clients and email one-time passcode guests, strict location enforcement (public preview) bounces unlisted egress IPs, and break-glass accounts belong in an exclusion on every policy. In an incident, remove planted methods and inbox rules before you revoke: a password reset alone does nothing to a stolen session cookie.

A finance user reports an Outlook rule that files anything mentioning "invoice" into RSS Feeds. The sign-in logs are all green: MFA satisfied, no failures, nothing risky. That is what AiTM token theft looks like from the defender's chair. The attacker never faced your MFA prompt, because the session they stole already carried proof that MFA happened.

This guide covers the full loop for Entra ID: hunt the replay, alert on it, then take the stolen session's value away with authentication strengths, token protection and continuous access evaluation (CAE). Every control comes with the check that proves it works.

TL;DR: how to stop AiTM token theft

  • Detect: hunt for one SessionId seen from two or more autonomous systems with a change in OS or browser, across SigninLogs and AADNonInteractiveUserSignInLogs. Correlate with the Entra ID Protection Anomalous token and Attacker in the Middle detections.
  • Prevent: require the built-in Phishing-resistant MFA authentication strength (FIDO2/passkey, Windows Hello for Business or platform credential, multifactor certificate-based authentication). A reverse proxy cannot relay these.
  • Bind: require token protection for sign-in sessions on Windows, iOS/iPadOS and macOS native apps, so a replayed refresh token from another device is rejected.
  • Revoke fast: CAE-aware apps honour session revocation in near real time instead of waiting out a token lifetime.

The single control that matters most:

Conditional Access > Grant > Require authentication strength > Phishing-resistant MFA
(report-only first, break-glass accounts excluded)

What is AiTM token theft?

AiTM token theft is an attack in which a reverse-proxy phishing site relays a victim's real sign-in to Microsoft Entra ID, lets the victim complete MFA, then captures the session cookie the service issues. The attacker replays that cookie from their own infrastructure and inherits an authenticated session without touching MFA again.

The authentication behind AiTM token theft is legitimate, which is why it looks clean. Microsoft's session cookie theft alert guide describes exactly this proxy pattern and notes that AiTM is usually a means to an end, most often business email compromise. Detection therefore has to move downstream, to how the session behaves after it is issued.

What do you need before you start?

Requirement Minimum Used for
Entra ID P1 Conditional Access, authentication strengths, token protection Prevention and binding
Entra ID P2 Full ID Protection detections, risk-based policies Anomalous token signals
Microsoft 365 E5 + EMS E5 Attacker in the Middle detection High-precision AiTM signal
Diagnostic settings SignInLogs and NonInteractiveUserSignInLogs to Log Analytics Replay hunting
Roles Conditional Access Administrator, Microsoft Sentinel Contributor Policies and rules
Tooling Microsoft Graph PowerShell SDK (v1.0 and beta modules), Azure CLI Scripted deployment

Two gotchas. Replayed refresh tokens land in the non-interactive log, so a workspace that only ingests interactive sign-ins is blind to most AiTM token theft. And the Entra data retention reference keeps sign-ins in the portal for 30 days on P1 and P2 (seven on Free), so route them to Log Analytics for a real hunting window. Confirm both streams are arriving:

union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(1d)
| summarize Rows = count(), Latest = max(TimeGenerated) by Type

You want two rows. One row means the non-interactive category is not exported.

How does an AiTM proxy steal and replay a token?

The flow below shows where each control breaks the chain:

  1. The user clicks a lookalike link and lands on the attacker's reverse proxy.
  2. The proxy relays the real Microsoft sign-in page; the user completes MFA.
  3. Entra ID issues a session cookie through the proxy, which forwards it to an attacker host on a different network.
  4. The attacker replays the cookie. Conditional Access re-evaluates, and without binding or location enforcement, access is granted.
  5. Phishing-resistant MFA stops step 2 (the credential is bound to the real origin). Token protection and CAE stop step 4.
lookalike link relay issue exfiltrate replay allow if unbound relay fails reject replay Phishing-resistant auth strength Token protection + CAE User AiTM reverse proxy Entra ID sign-in MFA completed Session cookie + refresh token Attacker host new ASN Conditional Access re-evaluation Microsoft 365 data

How to detect token replay in Entra ID sign-in logs

The pivot is SessionId, which both sign-in tables expose (SigninLogs schema, AADNonInteractiveUserSignInLogs schema). A replayed cookie keeps the victim's session ID but arrives from a new autonomous system (ASN) and, usually, a different OS or browser. One trap: DeviceDetail and LocationDetails are dynamic in SigninLogs but string in the non-interactive table, so a naive union splits them into suffixed columns. Normalise each leg first:

// AiTM token theft hunt: one SessionId, 2+ ASNs, device-context change
let lookback = 3d;
let interactive = SigninLogs
    | where TimeGenerated > ago(lookback) and ResultType == "0" and isnotempty(SessionId)
    | project TimeGenerated, UserPrincipalName, SessionId, IPAddress, AutonomousSystemNumber,
              AppDisplayName, Dev = DeviceDetail, Loc = LocationDetails;
let noninteractive = AADNonInteractiveUserSignInLogs
    | where TimeGenerated > ago(lookback) and ResultType == "0" and isnotempty(SessionId)
    | project TimeGenerated, UserPrincipalName, SessionId, IPAddress, AutonomousSystemNumber,
              AppDisplayName, Dev = todynamic(DeviceDetail), Loc = todynamic(LocationDetails);
union interactive, noninteractive
| extend OS = tostring(Dev.operatingSystem), Browser = tostring(Dev.browser),
         Country = tostring(Loc.countryOrRegion)
| summarize FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated),
            ASNCount  = dcount(AutonomousSystemNumber),
            ASNs      = make_set(AutonomousSystemNumber, 10),
            IPs       = make_set(IPAddress, 10),
            OSes      = make_set_if(OS, isnotempty(OS), 5),
            Browsers  = make_set_if(Browser, isnotempty(Browser), 5),
            Countries = make_set_if(Country, isnotempty(Country), 5),
            Apps      = make_set(AppDisplayName, 10)
          by SessionId, UserPrincipalName
| where ASNCount > 1
| extend SpanMinutes = datetime_diff('minute', LastSeen, FirstSeen),
         Signal = case(array_length(OSes) > 1, "os-change",
                       array_length(Browsers) > 1, "browser-change",
                       "asn-change-only"),
         IPAddress = tostring(IPs[0])
| where Signal != "asn-change-only" or SpanMinutes < 120
| project UserPrincipalName, SessionId, FirstSeen, LastSeen, SpanMinutes, Signal,
          ASNs, Countries, IPAddress, IPs, OSes, Browsers, Apps
| order by FirstSeen desc

An ASN change alone is noisy: VPN flips and egress failover produce it. An ASN change plus a different OS inside one session is a strong AiTM token theft signal. Run it over three days, then allow-list your VPN and ZTNA egress ASNs before you alert on it.

Next, look for the BEC hand-off that follows AiTM token theft: an inbox rule plus new security info or a newly registered device for the same user. Activity names come from the Entra audit activity reference and the OfficeActivity schema:

let window = 1d;
let mailRules = OfficeActivity
    | where TimeGenerated > ago(window)
    | where Operation in ("New-InboxRule", "Set-InboxRule", "UpdateInboxRules")
    | project TimeGenerated, Actor = tolower(UserId), Activity = Operation;
let identityChanges = AuditLogs
    | where TimeGenerated > ago(window)
    | where OperationName in ("User registered security info", "Admin registered security info", "Register device")
    | project TimeGenerated, Actor = tolower(tostring(InitiatedBy.user.userPrincipalName)), Activity = OperationName;
union mailRules, identityChanges
| summarize RuleEvents = countif(Activity has "InboxRule"),
            IdentityEvents = countif(Activity !has "InboxRule"),
            Activities = make_set(Activity), First = min(TimeGenerated), Last = max(TimeGenerated)
          by Actor
| where RuleEvents > 0 and IdentityEvents > 0

Finally, correlate with Microsoft's own signals. ID Protection risk detections include Anomalous token (anomalousToken, P2) and Attacker in the Middle (attackerinTheMiddle, a high-precision user-risk detection that needs Microsoft 365 E5 with EMS E5). Microsoft warns that Anomalous token still produces false positives at low and medium risk. With Defender for Cloud Apps connectors and Defender for Endpoint, Defender XDR also raises Stolen session cookie was used and Possible AiTM phishing attempt, according to Protecting tokens in Microsoft Entra ID.

AADUserRiskEvents
| where TimeGenerated > ago(7d)
| where RiskEventType in ("anomalousToken", "attackerinTheMiddle", "unfamiliarFeatures")
| project TimeGenerated, UserPrincipalName, RiskEventType, RiskLevel, DetectionTimingType, IpAddress, CorrelationId

How to turn the replay hunt into a Sentinel analytics rule

A saved AiTM token theft hunt that nobody opens is not a detection. Wrap the hunt in a scheduled analytics rule with entity mapping (body fields per the Alert Rules REST reference). Change lookback to 6h to match queryPeriod before pasting it in:

{
  "kind": "Scheduled",
  "properties": {
    "displayName": "AiTM token theft - session replayed across autonomous systems",
    "description": "One Entra ID SessionId seen from two or more ASNs with a device-context change.",
    "severity": "High",
    "enabled": true,
    "query": "<the hunt query above, lookback = 6h, JSON-escaped>",
    "queryFrequency": "PT30M",
    "queryPeriod": "PT6H",
    "triggerOperator": "GreaterThan",
    "triggerThreshold": 0,
    "suppressionDuration": "PT1H",
    "suppressionEnabled": false,
    "tactics": ["CredentialAccess", "DefenseEvasion"],
    "techniques": ["T1539", "T1550"],
    "incidentConfiguration": {
      "createIncident": true,
      "groupingConfiguration": {
        "enabled": true, "matchingMethod": "Selected", "groupByEntities": ["Account"],
        "lookbackDuration": "PT4H", "reopenClosedIncident": false
      }
    },
    "entityMappings": [
      { "entityType": "Account", "fieldMappings": [{ "identifier": "FullName", "columnName": "UserPrincipalName" }] },
      { "entityType": "IP", "fieldMappings": [{ "identifier": "Address", "columnName": "IPAddress" }] }
    ]
  }
}
SUB="00000000-0000-0000-0000-000000000000"; RG="sec-rg"; WS="sentinel-prod"; RULE_ID="aitm-session-replay"
URI="https://management.azure.com/subscriptions/${SUB}/resourceGroups/${RG}/providers/Microsoft.OperationalInsights/workspaces/${WS}/providers/Microsoft.SecurityInsights/alertRules/${RULE_ID}?api-version=2025-09-01"

az rest --method put --uri "$URI" --body @aitm-session-replay.json
# Verification: the rule exists, is enabled, and runs every 30 minutes
az rest --method get --uri "$URI" --query "properties.{name:displayName, enabled:enabled, freq:queryFrequency}" -o table

The rule shows up under Microsoft Sentinel > Configuration > Analytics in the Defender portal. Microsoft states that Sentinel in the Azure portal is supported until March 31, 2027; after that it lives only in the Defender portal.

How to enforce phishing-resistant MFA with authentication strengths

Detection shortens dwell time; prevention stops the proxy harvesting a usable session. The built-in Phishing-resistant MFA strength allows only Windows Hello for Business or platform credential, FIDO2 security keys, and multifactor certificate-based authentication, per the authentication strengths overview. The built-in strength IDs come from the Graph list call: ...0002 MFA, ...0003 Passwordless MFA, ...0004 Phishing resistant MFA.

Connect-MgGraph -Scopes "Policy.ReadWrite.ConditionalAccess","Policy.Read.All","Group.Read.All"

# Confirm the built-in IDs in your tenant before you reference one
Get-MgPolicyAuthenticationStrengthPolicy -Filter "policyType eq 'builtIn'" | Select-Object Id, DisplayName

$phishingResistant = "00000000-0000-0000-0000-000000000004"
$pilotGroupId      = (Get-MgGroup -Filter "displayName eq 'CA-PhishResistant-Pilot'").Id
$breakGlassGroupId = (Get-MgGroup -Filter "displayName eq 'CA-Exclude-BreakGlass'").Id

$policy = @{
    displayName   = "CA120 - Require phishing-resistant MFA for high-risk cohort"
    state         = "enabledForReportingButNotEnabled"   # report-only first
    conditions    = @{
        users          = @{ includeGroups = @($pilotGroupId); excludeGroups = @($breakGlassGroupId) }
        applications   = @{ includeApplications = @("All") }
        clientAppTypes = @("all")
    }
    grantControls = @{
        operator               = "OR"
        builtInControls        = @()
        authenticationStrength = @{ id = $phishingResistant }
    }
}
New-MgIdentityConditionalAccessPolicy -BodyParameter $policy

Keep builtInControls empty: Microsoft does not support Require multifactor authentication and Require authentication strength in the same policy. Start with privileged roles and finance approvers, and leave the policy in report-only for a full business week.

How to bind sessions with token protection

Phishing-resistant MFA stops the proxy half of AiTM token theft. It does not stop malware lifting a session from an already-signed-in endpoint, so bind the token to the device. This is where the original draft was out of date. Per the current token protection page, native-app support is generally available on Windows, iOS/iPadOS and macOS, and browser-based support is in preview, limited to selected web apps that access Azure Resource Manager. Native enforcement covers Exchange Online, SharePoint Online and Microsoft Teams, plus Azure Virtual Desktop and Windows 365 on Windows. The Apple device requirements (MDM-managed, Enterprise SSO plug-in) still carry a preview label on that page, so treat Apple rollout as a pilot.

The Windows deployment guide lists the licence as Entra ID P1 and prescribes three settings that the old draft got wrong: target only Exchange, SharePoint and Teams Services, set the platform to Windows, and limit client apps to Mobile apps and desktop clients. The session control is beta-only in Graph (secureSignInSession in the beta session controls resource):

$tokenPolicy = @{
    displayName     = "CA130 - Require token protection (Windows pilot)"
    state           = "enabledForReportingButNotEnabled"
    conditions      = @{
        users          = @{ includeGroups = @($pilotGroupId); excludeGroups = @($breakGlassGroupId) }
        applications   = @{ includeApplications = @(
            "00000002-0000-0ff1-ce00-000000000000",   # Office 365 Exchange Online
            "00000003-0000-0ff1-ce00-000000000000",   # Office 365 SharePoint Online
            "cc15fd57-2c6c-4117-a88c-83b1d56b4bbe") } # Microsoft Teams Services
        platforms      = @{ includePlatforms = @("windows") }
        clientAppTypes = @("mobileAppsAndDesktopClients")   # leaving Browser in can block Teams web
    }
    sessionControls = @{ secureSignInSession = @{ isEnabled = $true } }
}
New-MgBetaIdentityConditionalAccessPolicy -BodyParameter $tokenPolicy

Verification: the TokenProtectionStatusDetails column shows whether each request was bound.

AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(7d) and isnotempty(TokenProtectionStatusDetails)
| extend tp = parse_json(TokenProtectionStatusDetails)
| summarize Requests = count() by Status = tostring(tp.signInSessionStatus),
            Code = tostring(tp.signInSessionStatusCode), AppDisplayName

bound is the goal. Code 1002 means no Entra device state, 1003 an unsupported registration type, 1006 an unsupported OS, 1008 a client not using the platform broker.

How does continuous access evaluation shorten the replay window?

Without CAE, the access token an attacker holds after AiTM token theft stays valid until it expires (one hour by default), even after you revoke the user. The CAE overview says CAE-capable resources (Exchange, SharePoint, Teams) subscribe to critical events such as session revocation, password reset and high user risk, and act on them in near real time, with up to 15 minutes of propagation latency. IP-location enforcement is instant. CAE sessions use long-lived tokens (up to 28 hours) precisely because revocation no longer depends on expiry.

Critical event evaluation needs no policy. Strict location enforcement removes the split-tunnel exception; the CAE overview labels it public preview. Configure it in the portal (Session > Customize continuous access evaluation > Strictly enforce location policies) on an existing IP-based location policy, or with Graph beta, where strictLocation is an evolvable enum that needs the Prefer header (resource reference):

$policyId = "<id of your IP-based location policy>"
$body = @{ sessionControls = @{ continuousAccessEvaluation = @{ mode = "strictLocation" } } } | ConvertTo-Json -Depth 5
Invoke-MgGraphRequest -Method PATCH `
  -Uri "https://graph.microsoft.com/beta/identity/conditionalAccess/policies/$policyId" `
  -Headers @{ Prefer = "include-unknown-enum-members" } -Body $body -ContentType "application/json"

Before you switch it on for anyone, find the IPs that only resources see (the "IP address (seen by resource)" field), as the strict enforcement guide recommends:

SigninLogs
| where TimeGenerated > ago(14d)
| where isnotempty(IPAddressFromResourceProvider) and IPAddressFromResourceProvider != IPAddress
| summarize SignIns = count(), Users = dcount(UserPrincipalName) by IPAddressFromResourceProvider, AppDisplayName

Every row is an egress IP missing from your named locations.

How to verify each control actually fires

Report-only impact. Same normalisation trick, because ConditionalAccessPolicies is dynamic in one table and string in the other:

let names = dynamic(["CA120 - Require phishing-resistant MFA for high-risk cohort", "CA130 - Require token protection (Windows pilot)"]);
union
  (SigninLogs | project TimeGenerated, UserPrincipalName, AppDisplayName, CAP = todynamic(tostring(ConditionalAccessPolicies))),
  (AADNonInteractiveUserSignInLogs | project TimeGenerated, UserPrincipalName, AppDisplayName, CAP = todynamic(ConditionalAccessPolicies))
| where TimeGenerated > ago(7d)
| mv-expand ca = CAP
| where tostring(ca.displayName) in (names)
| summarize SignIns = count(), Users = dcount(UserPrincipalName), Apps = make_set(AppDisplayName, 15)
          by Policy = tostring(ca.displayName), Outcome = tostring(ca.result)

Work every reportOnlyFailure row to zero (register a passkey, fix a device registration) before enabling.

Prove the block. This is the test that shows AiTM token theft can no longer harvest a usable session. Enable CA120 for one test account that has only a password and Authenticator push. Sign in: it must fail, and the log must show CA120 as the failing policy.

Connect-MgGraph -Scopes "AuditLog.Read.All","Directory.Read.All"
Get-MgAuditLogSignIn -Filter "userPrincipalName eq 'svc.catest@contoso.com'" -Top 5 |
  Select-Object CreatedDateTime, ConditionalAccessStatus,
    @{n='Error';e={$_.Status.ErrorCode}},
    @{n='CA120';e={($_.AppliedConditionalAccessPolicies | Where-Object DisplayName -like 'CA120*').Result}}

Prove revocation. Revoke-MgUserSignInSession invalidates refresh tokens and browser session cookies by resetting signInSessionsValidFromDateTime. Revoke the test account, then use Outlook desktop: with CAE it should be challenged within minutes.

Connect-MgGraph -Scopes "User.RevokeSessions.All","User.Read.All"
Revoke-MgUserSignInSession -UserId "svc.catest@contoso.com"
(Get-MgUser -UserId "svc.catest@contoso.com" -Property SignInSessionsValidFromDateTime).SignInSessionsValidFromDateTime

What breaks when you enforce these controls?

  • Token protection blocks, it does not skip. The Windows guide says PowerShell modules accessing SharePoint, VS Code extensions reaching Exchange or SharePoint, and PowerQuery outside Current Channel are blocked, as are Office perpetual clients. Unsupported registrations (Entra-joined Cloud PCs and AVD hosts, Autopilot self-deploying devices) need a device-filter exclusion.
  • Authentication strength strands legacy clients. Protocols that cannot perform an interactive phishing-resistant method fail; block legacy authentication in its own policy and confirm with ClientAppUsed. Users who sign in with a password first are not offered Windows Hello for Business and must restart sign-in with Sign-in options. Email one-time passcode guests cannot satisfy any strength.
  • Strict location bounces unlisted egress. Shared or non-enumerable egress IPs (some SSE and split-tunnel designs) are exactly what Microsoft says not to enable it on. CAE also does not cover guest accounts.
  • Break-glass accounts. Exclude two emergency accounts from every policy here and alert on their use. If they use FIDO2 keys they could satisfy CA120, but an exclusion keeps a strength misconfiguration from locking out the tenant.

What should the first 30 minutes of an AiTM incident look like?

When the rule fires, remove persistence before you revoke, or the attacker re-enters with a method they planted. Defender's grading guide recommends resetting credentials and revoking tokens for a true positive.

Minute Action Where
0–5 Confirm replay: one SessionId, two ASNs, device change Hunt query filtered to the user
5–10 Enumerate new auth methods, devices, inbox rules, forwarding Get-MgUserAuthenticationMethod, Get-MgUserRegisteredDevice, Get-InboxRule
10–15 Remove planted methods and rules Type-specific Remove-MgUserAuthentication*Method, Remove-InboxRule
15–20 Revoke sessions, reset the password Revoke-MgUserSignInSession
20–30 Scope what the session touched OfficeActivity (MailItemsAccessed), CloudAppEvents

A password reset alone does nothing to a stolen session cookie. Revocation is the step that ends AiTM token theft, and CAE is what makes it take effect in minutes. Build the layers in order: phishing-resistant credentials remove the harvest, token binding removes the replay's value, CAE closes the revocation window, and the hunt catches whatever slips past all three.

Sources

Related Articles

IT professional covering Azure, DevOps, Security, and Automation. Helping IT pros build and automate better.

Leave a Reply

Your email address will not be published. Required fields are marked *