MFA stops password spray. It does almost nothing against token theft. Once an attacker has lifted a refresh token, a Primary Refresh Token (PRT), or a session cookie off an endpoint, they replay it from their own infrastructure and Entra hands them an access token - no password, no MFA prompt, because the original sign-in already satisfied those controls. This is the dominant pattern behind modern AiTM (adversary-in-the-middle) campaigns: phish the session, not the credential.
The defense is to make stolen artifacts useless off the device they were minted on. This guide wires together three Entra controls that, layered, collapse the token-replay kill chain: token protection (cryptographically binds the session to the client device), authentication context (tags sensitive apps and operations so they demand a fresh, stronger sign-in), and protected actions (forces step-up before tenant-level configuration changes - including changes to the very policies below).
The token theft kill chain
Understand what you are actually defending before you write a policy. Three artifacts matter:
| Artifact | Where it lives | What replaying it grants |
|---|---|---|
| Primary Refresh Token (PRT) | TPM-bound on Entra joined / hybrid joined Windows | SSO across all Entra apps on that device |
| Refresh token | Token cache (browser, MSAL, app) | New access tokens for a resource, silently, for the token’s lifetime |
| Session cookie | Browser cookie store | An authenticated web session, replayed in the attacker’s browser |
The PRT is the hardest target - it is protected by the TPM and, on a healthy device, the keys never leave hardware. The soft targets are refresh tokens and session cookies, which are bearer artifacts: whoever holds them is treated as the user. A “pass-the-cookie” attack is exactly that - copy the cookie, import it into a browser, and you are signed in. AiTM proxies (Evilginx and its descendants) automate the capture mid-sign-in, so even MFA gets relayed.
The core problem is that a bearer token has no notion of who is presenting it. Token protection fixes this by binding the issued token to a key the client device holds, so a copy presented from elsewhere fails validation.
1. Enable token protection session controls
Token protection (historically “token binding”) issues a session token cryptographically bound to a key held by the client device. A replayed token without proof-of-possession of that key is rejected. Build the policy in report-only first - this control has real platform constraints (covered in step 8) and will break sessions on unsupported clients if you enforce it blindly.
Portal path:
Entra admin center -> Protection -> Conditional Access -> Create new policy
Name: CA-TokenProtection-CoreApps
Users: pilot ring group (exclude break-glass)
Target: Cloud apps -> Office 365 Exchange Online, Office 365 SharePoint Online
Session: Require token protection for sign-in sessions
Enable: Report-only
Or as code via Microsoft Graph PowerShell - the session control is secureSignInSession:
Connect-MgGraph -Scopes "Policy.ReadWrite.ConditionalAccess","Policy.Read.All"
$params = @{
displayName = "CA-TokenProtection-CoreApps"
state = "enabledForReportingButNotEnforced" # report-only first
conditions = @{
users = @{
includeGroups = @("<pilot-ring-group-id>")
excludeUsers = @("<break-glass-object-id>")
}
applications = @{
# Exchange Online + SharePoint Online app IDs (well-known, stable)
includeApplications = @(
"00000002-0000-0ff1-ce00-000000000000", # Exchange Online
"00000003-0000-0ff1-ce00-000000000000" # SharePoint Online
)
}
clientAppTypes = @("all")
}
sessionControls = @{
secureSignInSession = @{ isEnabled = $true }
}
}
New-MgIdentityConditionalAccessPolicy -BodyParameter $params
Scope it to Exchange Online and SharePoint Online deliberately - at the time of writing these are the resources where the supported native clients (new Outlook, Teams, OneDrive sync) honor token protection on Entra joined / hybrid joined / compliant Windows endpoints. Targeting “All cloud apps” with this control today guarantees broken sessions on anything that does not yet support binding.
2. Validate device binding before you enforce
Report-only generates the signal you need without locking anyone out. After a few days of pilot traffic, query the sign-in logs for the token protection evaluation. The signal surfaces under the session controls in the sign-in detail; in Log Analytics you expand the applied policies:
SigninLogs
| where TimeGenerated > ago(7d)
| mv-expand pol = ConditionalAccessPolicies
| where tostring(pol.displayName) == "CA-TokenProtection-CoreApps"
// reportOnlyFailure here = the session WOULD have been blocked once enforced
| summarize attempts = count() by
Result = tostring(pol.result),
AppDisplayName,
ClientAppUsed,
DeviceDetail_OS = tostring(DeviceDetail.operatingSystem)
| order by attempts desc
You are looking for reportOnlyFailure rows. Each one is a session that real enforcement would have broken. Triage them by client and OS:
- Unsupported client (legacy Outlook, third-party mail app, mobile): expected - do not enforce against these until you have a migration plan.
- Unmanaged device: token protection needs a registered device with a usable key; an unregistered BYOD endpoint cannot bind. Pair this policy with a compliant-device requirement (step 6).
- Supported client failing: investigate before flipping to enforce - this is the population that would generate help-desk tickets.
Only when reportOnlyFailure is essentially zero for your supported-client population do you change state to enabled.
3. Design authentication context tags
Token protection hardens sessions. Authentication context lets you demand stronger, fresher auth for specific applications, SharePoint sites, or sensitive operations - independent of how the user signed in originally. It is the foundation for step-up.
An authentication context is a small tag (IDs c1 through c99) that you define once, then reference from Conditional Access and from applications that are auth-context-aware.
# Define a context tag for high-value resources
$ctx = @{
id = "c1"
displayName = "High-Value Data (step-up)"
description = "Finance, HR, and admin tooling - requires fresh phishing-resistant auth"
isAvailable = $true
}
New-MgIdentityConditionalAccessAuthenticationContextClassReference `
-AuthenticationContextClassReferenceId "c1" -BodyParameter $ctx
Then author a Conditional Access policy whose target is the context tag rather than an app. Any resource that requests c1 triggers it:
$stepUp = @{
displayName = "CA-AuthContext-c1-StepUp"
state = "enabledForReportingButNotEnforced"
conditions = @{
users = @{ includeUsers = @("All"); excludeUsers = @("<break-glass-object-id>") }
applications = @{ includeAuthenticationContextClassReferences = @("c1") }
}
grantControls = @{
operator = "OR"
authenticationStrength = @{
# built-in "Phishing-resistant MFA" strength
id = "00000000-0000-0000-0000-000000000004"
}
}
}
New-MgIdentityConditionalAccessPolicy -BodyParameter $stepUp
Now wire resources to the tag. The high-value patterns:
- SharePoint / OneDrive: label-driven. A sensitivity label with the “Use Conditional Access” setting points at
c1, so any site or document carrying that label inherits step-up. - Microsoft Defender for Cloud Apps: a session policy can trigger an auth-context challenge mid-session for risky in-app actions (downloads, admin operations).
- Your own apps: an app that uses MSAL can request the
acrsclaim forc1via theclaimsparameter, forcing the platform to satisfy your step-up policy before issuing a token.
The power here is decoupling. You are not protecting an app - you are protecting a classification of data, and the tag travels with it.
4. Wire protected actions to gate high-value operations
Protected actions take authentication context one layer deeper: they attach a context requirement to specific Entra directory permissions. The point is to stop an attacker who has already compromised an admin session from making catastrophic configuration changes - because the protected action forces a fresh, interactive step-up at the moment of the operation, not at sign-in.
This is the control that protects the rest of your design. Tie Conditional Access policy management itself to a protected action and an attacker with a stolen admin token cannot quietly disable your token-protection policy.
Configuration order matters - the context tag and its CA policy must exist first:
1. Define auth context (step 3) - e.g. "c2 : Tenant Config Change"
2. Create a CA policy targeting c2 with sign-in frequency = every time
+ phishing-resistant authentication strength (step 5)
3. Entra admin center -> Roles & admins -> Protected actions -> Add
Authentication context: c2
Permissions to protect, e.g.:
microsoft.directory/conditionalAccessPolicies/basic/update
microsoft.directory/conditionalAccessPolicies/delete
microsoft.directory/namedLocations/basic/update
microsoft.directory/crossTenantAccessPolicy/allowedCloudEndpoints/update
High-value permissions worth protecting:
| Permission | Why it is a protected-action candidate |
|---|---|
conditionalAccessPolicies/basic/update |
Stops an attacker disabling the controls in this article |
conditionalAccessPolicies/delete |
Same blast radius - deletion is not update |
namedLocations/basic/update |
Prevents adding an attacker IP as a “trusted” location |
crossTenantAccessPolicy/.../update |
Blocks opening B2B trust to a hostile tenant |
Protected actions also gate PIM activation when you point a role’s activation flow at the context. In the role setting under PIM, set “On activation, require Conditional Access authentication context” and select your tag - so activating Global Administrator forces the same fresh phishing-resistant challenge as your most sensitive data.
Identity Governance -> PIM -> Entra roles -> Global Administrator -> Settings
Activation:
[x] On activation, require Conditional Access authentication context -> "c2 : Tenant Config Change"
5. Step-up enforcement: phishing-resistant, every time
The grant control that makes step-up meaningful is a combination of authentication strength and sign-in frequency = every time. Sign-in frequency “every time” disables silent token reuse for that policy scope - the user must interactively re-authenticate on each evaluation, so a stolen token gets you nothing because the platform will not honor it without a live challenge.
$everyTime = @{
displayName = "CA-AuthContext-c2-TenantConfig"
state = "enabledForReportingButNotEnforced"
conditions = @{
users = @{ includeUsers = @("All"); excludeUsers = @("<break-glass-object-id>") }
applications = @{ includeAuthenticationContextClassReferences = @("c2") }
}
grantControls = @{
operator = "AND"
authenticationStrength = @{ id = "00000000-0000-0000-0000-000000000004" } # phishing-resistant MFA
}
sessionControls = @{
signInFrequency = @{
isEnabled = $true
frequencyInterval = "everyTime"
authenticationType = "primaryAndSecondaryAuthentication"
}
}
}
New-MgIdentityConditionalAccessPolicy -BodyParameter $everyTime
Two design notes that matter:
frequencyInterval = "everyTime"is specifically supported for authentication-context and a small set of risk-based scenarios. It is not a general “re-auth every login” hammer; pairing it with a context tag is exactly its intended use.- Phishing-resistant authentication strength means FIDO2 security keys, Windows Hello for Business, or device-bound passkeys / certificate-based auth. Plain push or OTP does not satisfy strength
...0004. That is the point - AiTM proxies cannot relay a FIDO2 assertion the way they relay an OTP.
6. Combine with compliant-device and risk-based policies
Token protection assumes a registered device with a usable key, so it composes naturally with device compliance. Layer the controls as single-purpose policies rather than one mega-policy:
| Policy | Grant / Session control | Job |
|---|---|---|
| Token protection (step 1) | secureSignInSession |
Bind sessions to the device |
| Require compliant device | compliantDevice / domainJoinedDevice |
Guarantee a managed, healthy endpoint that can bind |
| Sign-in risk | High -> block, Medium -> require step-up auth context | Catch anomalous replay from new geo/IP |
| Auth context step-up (steps 3-5) | phishing-resistant + every-time | Fresh strong auth for sensitive data and actions |
The risk-based piece is the interesting interaction. AiTM replay from attacker infrastructure frequently lands as medium or high sign-in risk (impossible travel, anonymous IP, unfamiliar properties). Route medium sign-in risk into your authentication-context step-up so a risky session cannot touch c1 data without re-proving identity with a phishing-resistant method:
$riskStepUp = @{
displayName = "CA-Risk-StepUp-to-AuthContext"
state = "enabledForReportingButNotEnforced"
conditions = @{
users = @{ includeUsers = @("All"); excludeUsers = @("<break-glass-object-id>") }
applications = @{ includeApplications = @("All") }
signInRiskLevels = @("high","medium")
}
grantControls = @{
operator = "AND"
authenticationStrength = @{ id = "00000000-0000-0000-0000-000000000004" }
}
sessionControls = @{
signInFrequency = @{ isEnabled = $true; frequencyInterval = "everyTime"; authenticationType = "primaryAndSecondaryAuthentication" }
}
}
New-MgIdentityConditionalAccessPolicy -BodyParameter $riskStepUp
Sign-in risk policies require Entra ID P2. Token protection itself requires Entra ID P1 plus supported endpoints.
7. Monitor for token protection failures and replay attempts
Three signals tell you whether the controls are working and whether you are under attack.
Token protection enforcement results - watch the transition from report-only failures to enforced blocks, and alert on supported clients failing (a possible replay attempt or a binding regression):
SigninLogs
| where TimeGenerated > ago(1d)
| mv-expand pol = ConditionalAccessPolicies
| where tostring(pol.displayName) startswith "CA-TokenProtection"
| where tostring(pol.result) in ("failure","reportOnlyFailure")
| project TimeGenerated, UserPrincipalName, AppDisplayName, ClientAppUsed,
IPAddress, Result = tostring(pol.result),
OS = tostring(DeviceDetail.operatingSystem),
Device = tostring(DeviceDetail.deviceId)
| order by TimeGenerated desc
Cookie / token replay detection - Entra ID Protection raises an Anomalous Token and Token Issuer Anomaly risk detection specifically for sessions that look replayed. Surface them:
AADUserRiskEvents
| where TimeGenerated > ago(7d)
| where RiskEventType in ("anomalousToken", "tokenIssuerAnomaly")
| project TimeGenerated, UserPrincipalName, RiskEventType, RiskLevel,
IpAddress, Source, DetectionTimingType
| order by TimeGenerated desc
Protected action / config-change auditing - any change to the policies themselves should be loud:
AuditLogs
| where TimeGenerated > ago(7d)
| where OperationName has_any ("conditional access policy", "Update conditional access policy",
"Add named location", "Update named location")
| project TimeGenerated, OperationName,
Actor = tostring(InitiatedBy.user.userPrincipalName),
Target = tostring(TargetResources[0].displayName), Result
| order by TimeGenerated desc
Stream SigninLogs, AADUserRiskEvents, and AuditLogs to Microsoft Sentinel and alert on: any enforced token-protection failure on a supported client, any anomalousToken detection, and any modification to a CA policy or named location.
8. Phased deployment and known limitations
Roll this out in rings, never tenant-wide on day one. Report-only first, every policy. The platform limitations are real and will bite an aggressive rollout:
- Token protection is currently limited to sign-in sessions on Windows (Entra joined, hybrid joined, or compliant) with a narrow set of supported native clients (new Outlook, Teams, OneDrive sync). macOS, iOS, Android, browsers, and many third-party clients do not yet bind - enforcing against them breaks sessions. Scope tightly and watch report-only signal before enforcing.
frequencyInterval = "everyTime"is supported for authentication-context and specific risk scenarios, not as a universal control.- Protected actions apply to a defined set of Entra directory permissions; not every operation is eligible, and they govern directory operations, not Azure resource RBAC.
- Break-glass exclusion is mandatory. Exclude two monitored cloud-only emergency accounts from every policy here, and do not put auth-context or token-protection requirements on them - a misconfiguration must never strand recovery.
A sane sequence: pilot ring -> wider ring -> targeted enforcement on supported clients only -> expand coverage as Microsoft broadens platform support.
Enterprise scenario
A platform team at a financial-services firm had a clean Conditional Access estate - MFA everywhere, compliant devices for admins, risk policies on P2 - and still took a hit. An engineer was phished through an AiTM proxy; the attacker captured the session and replayed the refresh token from a hosting provider in another region. Because the original sign-in had satisfied MFA, Entra issued access tokens silently. The attacker reached Exchange Online and began mailbox rules exfiltration before Identity Protection flagged the session as anomalousToken - roughly 30 minutes of access.
The constraint was that they could not simply turn on token protection tenant-wide. Their fleet was mixed: Windows for engineering, but a large macOS population in the business units and heavy mobile Outlook use. Enforcing secureSignInSession broadly would have broken thousands of sessions overnight.
The solution was to split the problem by data value, not by user. They enforced token protection only on the Windows engineering ring against Exchange and SharePoint - where every endpoint was Entra joined and ran supported clients, so reportOnlyFailure was already near zero. For everyone else, they leaned on authentication context plus every-time phishing-resistant step-up routed off sign-in risk: a replayed session lands as medium/high risk, which now forced a FIDO2 challenge before any c1-labeled finance data could be opened. The replay token the attacker held could not produce a FIDO2 assertion, so the high-value data stayed sealed even on platforms that could not bind tokens. They then put CA-policy management itself behind a protected action so the attacker - even with an admin token - could not have disabled the new controls.
# The hinge: medium/high sign-in risk -> phishing-resistant step-up, no silent reuse.
# This covered the macOS/mobile population that token protection could not yet bind.
$params = @{
displayName = "CA-Risk-ForcePhishResistant-StepUp"
state = "enabled"
conditions = @{
users = @{ includeUsers = @("All"); excludeUsers = @("<break-glass-object-id>") }
applications = @{ includeAuthenticationContextClassReferences = @("c1") }
signInRiskLevels = @("high","medium")
}
grantControls = @{ operator = "AND"; authenticationStrength = @{ id = "00000000-0000-0000-0000-000000000004" } }
sessionControls = @{ signInFrequency = @{ isEnabled = $true; frequencyInterval = "everyTime"; authenticationType = "primaryAndSecondaryAuthentication" } }
}
New-MgIdentityConditionalAccessPolicy -BodyParameter $params
The lesson was that token protection is the strongest control but the most platform-constrained, so it cannot be your only answer. Bind what you can; for everything else, make the stolen token worthless by demanding a fresh phishing-resistant proof the attacker physically cannot produce.
Verify
Confirm each control end to end before declaring victory:
- Token binding works - from a supported Windows client in the pilot ring, sign in to Outlook/Teams and confirm in
SigninLogsthat the token-protection policy result issuccess. Then attempt to replay that session’s cookie from a different machine and confirm it fails. - Auth context fires - open a
c1-labeled SharePoint document and confirm you are prompted for the step-up phishing-resistant method even though you already had a session. - Protected action gates config - as a privileged admin with a normal (non-stepped-up) session, attempt to edit a Conditional Access policy and confirm Entra forces the
c2step-up challenge before the change is allowed. - PIM activation steps up - activate a role wired to the context and confirm the every-time phishing-resistant challenge appears at activation.
- Monitoring is live - trigger a deliberate failure on an unsupported client and confirm the
SigninLogsKQL and the Sentinel alert both fire.
Rollout checklist
Token theft is the post-MFA attack, and the answer is post-MFA defense. Token protection makes a stolen session cryptographically worthless where the platform supports it; authentication context and protected actions cover the rest by demanding a fresh, phishing-resistant proof at the moment of access - one an attacker holding only a bearer token cannot produce. Layer all three, gate your own controls behind a protected action, and the replay kill chain has nowhere to land.