Security Azure

Stopping Token Theft: Conditional Access Token Protection and Authentication Context

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:

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:

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:

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:

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:

  1. Token binding works - from a supported Windows client in the pilot ring, sign in to Outlook/Teams and confirm in SigninLogs that the token-protection policy result is success. Then attempt to replay that session’s cookie from a different machine and confirm it fails.
  2. 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.
  3. 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 c2 step-up challenge before the change is allowed.
  4. PIM activation steps up - activate a role wired to the context and confirm the every-time phishing-resistant challenge appears at activation.
  5. Monitoring is live - trigger a deliberate failure on an unsupported client and confirm the SigninLogs KQL 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.

Conditional-Accesstoken-protectionauthentication-contextprotected-actionsEntratoken-theft

Comments

Keep Reading