Device Code Phishing: The OAuth Attack That Bypasses MFA
MFA was enforced. The tenant had a third-party MFA provider through Conditional Access. Every sign-in log entry showed a successful authentication. No password spray. No credential theft. And yet the attacker had a valid refresh token and access to the mailbox.
The entry point was a device code phishing link. Once I understood the mechanism, everything made sense.
What the device code flow was built for
The OAuth 2.0 Device Authorization Grant exists to solve a real problem: some devices cannot open a browser or accept keyboard input easily. Think smart TVs, CLI tools, IoT sensors, network printers. The legitimate flow works like this:
- The device requests a short-lived user code from Microsoft's authorization server
- The device displays that code and tells the user to go to
microsoft.com/deviceloginon another device - The user opens the URL on their phone or laptop, enters the code, and completes authentication normally (including MFA)
- Microsoft issues a token back to the original device that initiated the request
The design assumption is that the person who owns the device and the person entering the code are the same. Device code phishing breaks that assumption.
How attackers weaponize it
The attacker starts the request (not the victim). Here is the sequence:
- The attacker calls Microsoft's token endpoint and requests a device code. Microsoft issues a user code valid for 15 minutes and starts a polling loop on the attacker's session.
- The attacker sends the victim a phishing message containing the code. Lures vary: fake invoice notifications, SharePoint access requests, "your MFA needs to be re-verified" prompts. The link either embeds the code directly in the URL or instructs the user to enter it manually.
- The victim clicks the link, lands on the real
microsoft.com/devicelogin, enters the code, and completes their normal login (including MFA). - Microsoft resolves the device code against the attacker's polling session and issues a refresh token to that session.
- The attacker holds a valid refresh token tied to the victim's account. MFA has been satisfied. No password was stolen.
The victim authenticated to a legitimate Microsoft page and completed every security step correctly. The mechanism itself handed the token to the wrong party.
Why MFA doesn't stop this
This is the part that trips up analysts and gets miscommunicated in incident reports. MFA was not bypassed in the traditional sense. The victim completed MFA. The authentication was genuine. What device code phishing exploits is the token handoff (the step where the credential is issued to whoever initiated the device code request, which is the attacker's polling session).
Standard TOTP codes, push notifications, and SMS all share the same problem: they verify the user's identity but have no mechanism to verify that the device waiting for the token is the user's device. Phishing-resistant MFA methods like FIDO2 hardware keys and passkeys are bound to specific origins and cannot be replayed by an attacker holding a device code. They are the only MFA types that actually prevent this attack at the authentication layer.
A logging nuance that causes bad incident conclusions: When a tenant uses a third-party MFA provider like Duo or Okta via Conditional Access, Entra sign-in logs often record the authentication event with a single-factor method. The MFA step executes outside Entra's native flow and does not always register in Entra telemetry. If an analyst sees "single-factor authentication" in the logs and concludes that MFA was not configured or not working, they are wrong. MFA was active. The attack succeeded regardless.
Token persistence after password reset
This one matters for incident response decisions. The refresh token issued through device code flow persists after a password reset. If you reset the victim's password without revoking active tokens, the attacker stays in. Their polling session does not care what the current password is (the token was already issued).
Token revocation is a required step, not an optional cleanup task. More on the full response sequence below.
What it looks like in Entra sign-in logs
The pattern is distinct once you know what to filter for. In Entra ID > Monitoring > Sign-in logs:
- Filter Authentication protocol for
deviceCode - Look for the successful device code authentication event tied to the victim
- Then look at
AADNonInteractiveUserSignInLogsin the same time window for the same user (this is where the attacker's polling session appears as a non-interactive sign-in from a different IP address)
The attacker infrastructure typically resolves to developer cloud platforms, residential proxy networks, or datacenter ASNs with no connection to the victim organization's normal geolocations.
KQL detection query for Entra / Sentinel
SigninLogs
| where AuthenticationProtocol == "deviceCode"
| project TimeGenerated, UserPrincipalName, IPAddress, AppDisplayName,
ResultType, ResultDescription, Location
| join kind=inner (
AADNonInteractiveUserSignInLogs
| project TimeGenerated, UserPrincipalName, IPAddress, ResourceDisplayName
) on UserPrincipalName
| where datetime_diff('minute', TimeGenerated1, TimeGenerated) between (0 .. 5)
and IPAddress != IPAddress1
| project TimeGenerated, UserPrincipalName, DeviceCodeIP=IPAddress,
TokenUsedFrom=IPAddress1, ResourceDisplayName
The join surfaces the gap: the IP address where the device code was authenticated (the victim's location) and the IP address where the resulting token was used (the attacker's infrastructure) are different. A five-minute window is a reasonable starting threshold (adjust based on what you see in your environment).
Defender and Entra ID Protection alerts
Microsoft Defender XDR surfaces an alert titled "Suspicious Azure authentication through possible device code phishing" when telemetry crosses its confidence threshold. Entra ID Protection surfaces the same pattern under anomalous authentication. When these alerts fire, treat them as high-priority. They are accurate signals, not noisy ones, and by the time they appear the attacker typically already has a working refresh token.
The control that actually stops this
Block device code flow in Conditional Access. This is the primary recommendation from Microsoft, CISA, and every IR practitioner I have talked to who has worked one of these. Most corporate users have no legitimate reason to authenticate via device code. The devices that actually need it (shared kiosks, CLI tools running under service accounts) are a small and known population that you can carve out explicitly if needed.
The default tenant configuration allows device code flow. If you have not explicitly blocked it, it is open.
To create the policy:
- Entra ID > Security > Conditional Access > New policy
- Users: All users (or the target scope you want to start with)
- Cloud apps: All cloud apps
- Conditions > Authentication flows > Device code flow > Yes
- Grant: Block access
- Enable the policy
Enable the policy in report-only mode for a few days before enforcing it. Review the sign-in logs to confirm the only device code authentications in your environment are accounts you expect. If you find legitimate use, scope those users or service accounts out before flipping to enforce.
The secondary control is phishing-resistant MFA. Deploying FIDO2 hardware keys or certificate-based authentication renders device code phishing useless at the authentication layer regardless of whether device code flow is blocked. If you are on a roadmap to phishing-resistant MFA, blocking device code flow in Conditional Access is the interim control you need in place while you get there.
Standard TOTP, push MFA, and SMS offer no protection against this attack.
Incident response checklist
When you confirm a device code phishing compromise, the response sequence matters more than most compromises because of the token persistence issue.
- Disable the account immediately. Do not just reset the password. Account disable stops all active sessions while you investigate.
- Revoke all active tokens. Update
StsRefreshTokensValidFromin Entra, or use "Revoke sessions" in the user's profile in the Entra admin center. This invalidates all refresh tokens issued before that timestamp. - Review sign-in logs for the 72 hours before and after the compromise window. Look for lateral movement and access to sensitive resources like SharePoint libraries, Teams channels, or admin portals.
- Check for OAuth app consents granted during the compromise window. Attackers chain device code phishing with app consent grants to register a persistent application that survives token revocation. Pull the unified audit log for
Add OAuth2PermissionGrantandConsent to applicationevents in the compromise window. - Audit inbox rules. Attackers pair device code phishing with inbox rule persistence. Run
Get-InboxRulewith-IncludeHiddenon the compromised mailbox. For a full walkthrough of inbox rule hunting in M365, see the BEC inbox rule persistence post. - Review email activity from the account during the compromise window. Check for reads on sensitive threads, forwarded messages, or draft-based exfiltration.
- Re-enable the account and require MFA re-enrollment after investigation is complete and tokens are revoked.
- Implement the CA policy to block device code flow before closing the incident. Closing an incident without implementing the control that would have prevented it is how you run the same investigation six months later with a different victim.
A password reset without token revocation leaves the attacker in the account. Confirm token revocation in the Entra audit log before you restore the account or mark the incident resolved.
Threat actor context
Storm-2372 and TA2723 ran device code phishing campaigns at significant scale starting in late 2024, targeting government, defense, and financial sector organizations. The technique is now being commoditized via phishing kits, which means smaller and less sophisticated actors have access to tooling that handles the polling loop and lure generation automatically. Volume will increase. The detection and mitigation haven't changed (blocking device code flow in Conditional Access is still the answer), but the "this only happens to high-value targets" framing no longer holds.
If you have not checked whether device code flow is blocked in your tenant, check today. The configuration is a single Conditional Access policy and it takes less time to build than reading this post.
Full Script
<#
.SYNOPSIS
Detection queries and incident response procedures for OAuth device code
phishing attacks against Microsoft 365 / Entra ID.
.DESCRIPTION
Device code phishing abuses the OAuth 2.0 Device Authorization Grant flow
to steal valid M365 access tokens without capturing passwords or triggering
a traditional MFA prompt. The victim completes a real Microsoft authentication
— including any MFA factor — but the resulting token is issued to the
attacker's polling session rather than a legitimate device.
The token persists through password resets. Only explicit token revocation
stops the attacker's access.
This script provides:
1. Detection functions using Entra/M365 PowerShell
2. Incident response (containment, investigation, evidence collection)
3. Tenant-wide proactive hunting for device code sign-in patterns
4. KQL queries for Microsoft Sentinel (in comment blocks)
Active threat groups using this technique (as of 2025):
- Storm-2372 (Russia-aligned, nation-state)
- TA2723 (financially motivated, SquarePhish tooling)
REQUIREMENTS:
Microsoft.Graph PowerShell module:
Install-Module Microsoft.Graph -Scope CurrentUser
Or Azure AD module:
Install-Module AzureAD -Scope CurrentUser
Required Graph scopes:
AuditLog.Read.All (sign-in log queries)
User.ReadWrite.All (disable accounts, revoke sessions)
Policy.Read.All (Conditional Access review)
Mail.Read (delegated) (inbox rule investigation — requires separate EXO connection)
Connect before running:
Connect-MgGraph -Scopes "AuditLog.Read.All","User.ReadWrite.All","Policy.Read.All"
Connect-ExchangeOnline -UserPrincipalName [email protected] # for inbox rules
#>
#Requires -Modules Microsoft.Graph.Authentication
Set-StrictMode -Version Latest
#region ─── Detection: Find Device Code Sign-In Events ───────────────────────
function Find-DeviceCodeSignIns {
<#
.SYNOPSIS
Queries Entra sign-in logs for device code authentication events.
.DESCRIPTION
Retrieves interactive sign-in events where the client used the Device
Code authentication flow. In a standard corporate environment, legitimate
device code sign-ins are rare. Any device code event from an unexpected
IP or location warrants investigation.
What you're looking for:
- Device code sign-ins from hosting/cloud provider ASNs where your
org has no employees (Railway, Psychz Networks, BV Networks, etc.)
- Device code sign-in followed immediately by non-interactive token
use from a DIFFERENT IP address — that's the attacker's session.
- Sign-ins via "Microsoft Office" or "Microsoft Authentication Broker"
application — these are the most commonly abused app registrations
in device code phishing.
.PARAMETER DaysBack
How many days of sign-in history to search. Default: 7.
.PARAMETER UserPrincipalName
Optional. Scope search to a specific user.
.EXAMPLE
Find-DeviceCodeSignIns
Find-DeviceCodeSignIns -DaysBack 30 -UserPrincipalName "[email protected]"
#>
param(
[Parameter(Mandatory = $false)]
[int]$DaysBack = 7,
[Parameter(Mandatory = $false)]
[string]$UserPrincipalName
)
$StartTime = (Get-Date).AddDays(-$DaysBack).ToString('yyyy-MM-ddTHH:mm:ssZ')
$Filter = "createdDateTime ge $StartTime and authenticationProtocol eq 'deviceCode'"
if ($UserPrincipalName) {
$Filter += " and userPrincipalName eq '$UserPrincipalName'"
}
Write-Host "`n[*] Searching for device code sign-in events (last $DaysBack days)..." -ForegroundColor Cyan
$SignIns = Get-MgAuditLogSignIn -Filter $Filter -All -ErrorAction Stop
if (-not $SignIns -or $SignIns.Count -eq 0) {
Write-Host " No device code sign-in events found in this window." -ForegroundColor Gray
return
}
Write-Host " Found $($SignIns.Count) device code sign-in event(s):`n" -ForegroundColor Yellow
$SignIns | Select-Object `
CreatedDateTime,
UserPrincipalName,
@{N='AppDisplayName'; E={ $_.AppDisplayName }},
@{N='IPAddress'; E={ $_.IPAddress }},
@{N='Location'; E={ "$($_.Location.City), $($_.Location.CountryOrRegion)" }},
@{N='ResultCode'; E={ $_.Status.ErrorCode }},
@{N='ResultDetail'; E={ $_.Status.AdditionalDetails }},
@{N='DeviceOS'; E={ $_.DeviceDetail.OperatingSystem }},
@{N='Browser'; E={ $_.DeviceDetail.Browser }},
CorrelationId |
Sort-Object CreatedDateTime |
Format-Table -AutoSize -Wrap
Write-Host "[i] Next step: Run Find-DeviceCodeFollowOnAccess with the IP addresses and usernames above." -ForegroundColor Gray
}
function Find-DeviceCodeFollowOnAccess {
<#
.SYNOPSIS
Finds non-interactive sign-in events that follow a device code sign-in
for the same user, indicating token use by the attacker.
.DESCRIPTION
Device code phishing produces a specific log pattern:
1. Interactive sign-in (device code flow) — the victim authenticating
2. Non-interactive sign-in from a DIFFERENT IP — the attacker using the token
This function finds non-interactive events for a user within a time window
and flags those from different IPs than the specified device code origin IP.
.PARAMETER UserPrincipalName
The compromised user's UPN.
.PARAMETER DeviceCodeIP
The IP address of the device code sign-in event.
.PARAMETER DeviceCodeTime
The timestamp of the device code sign-in event.
.PARAMETER WindowMinutes
How many minutes after the device code event to search. Default: 60.
.EXAMPLE
Find-DeviceCodeFollowOnAccess `
-UserPrincipalName "[email protected]" `
-DeviceCodeIP "203.0.113.1" `
-DeviceCodeTime "2026-03-18T14:41:52Z"
#>
param(
[Parameter(Mandatory = $true)]
[string]$UserPrincipalName,
[Parameter(Mandatory = $true)]
[string]$DeviceCodeIP,
[Parameter(Mandatory = $true)]
[string]$DeviceCodeTime,
[Parameter(Mandatory = $false)]
[int]$WindowMinutes = 60
)
$StartTime = $DeviceCodeTime
$EndTime = (Get-Date $DeviceCodeTime).AddMinutes($WindowMinutes).ToString('yyyy-MM-ddTHH:mm:ssZ')
$Filter = "userPrincipalName eq '$UserPrincipalName' and " +
"createdDateTime ge $StartTime and " +
"createdDateTime le $EndTime"
Write-Host "`n[*] Searching non-interactive sign-ins for $UserPrincipalName after device code event..." -ForegroundColor Cyan
$Events = Get-MgAuditLogSignIn -Filter $Filter -All -ErrorAction Stop
if ($Events) {
$Events | Where-Object { $_.IPAddress -ne $DeviceCodeIP } |
Select-Object `
CreatedDateTime,
@{N='IPAddress'; E={ $_.IPAddress }},
@{N='App'; E={ $_.AppDisplayName }},
@{N='Resource'; E={ $_.ResourceDisplayName }},
@{N='Location'; E={ "$($_.Location.City), $($_.Location.CountryOrRegion)" }},
@{N='DIFFERENT_IP'; E={ $_.IPAddress -ne $DeviceCodeIP }},
CorrelationId |
Sort-Object CreatedDateTime |
Format-Table -AutoSize
Write-Host "[!] Any rows above with DIFFERENT_IP=True indicate attacker token use from a different IP." -ForegroundColor Yellow
}
else {
Write-Host " No sign-in events found in this window." -ForegroundColor Gray
}
}
#endregion
#region ─── Incident Response: Containment ────────────────────────────────────
function Invoke-DeviceCodeContainment {
<#
.SYNOPSIS
Performs containment actions for a confirmed device code phishing compromise.
.DESCRIPTION
Containment steps in order:
1. Disable the account immediately (stops active sessions from starting new ones)
2. Revoke all refresh tokens (invalidates any tokens the attacker holds)
3. Document the containment timestamp
IMPORTANT: A password reset ALONE does NOT revoke existing OAuth refresh
tokens. The attacker's token remains valid until you explicitly revoke it.
Both actions (disable + revoke) are required.
Investigate BEFORE re-enabling the account. See Invoke-DeviceCodeIR
for the full investigation checklist.
.PARAMETER UserPrincipalName
The compromised user's UPN.
.PARAMETER WhatIf
Preview what would be done without making any changes.
.EXAMPLE
Invoke-DeviceCodeContainment -UserPrincipalName "[email protected]"
Invoke-DeviceCodeContainment -UserPrincipalName "[email protected]" -WhatIf
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory = $true)]
[string]$UserPrincipalName
)
Write-Host "`n[*] Starting containment for: $UserPrincipalName" -ForegroundColor Yellow
# Resolve User ID
$User = Get-MgUser -Filter "userPrincipalName eq '$UserPrincipalName'" -ErrorAction Stop
if (-not $User) {
Write-Error "User not found: $UserPrincipalName"
return
}
# Step 1: Disable the account
if ($PSCmdlet.ShouldProcess($UserPrincipalName, "Disable account (BlockCredential)")) {
Update-MgUser -UserId $User.Id -AccountEnabled:$false
Write-Host " [+] Account disabled: $UserPrincipalName" -ForegroundColor Green
}
# Step 2: Revoke all refresh tokens
# This updates StsRefreshTokensValidFrom to the current time, invalidating
# all existing refresh tokens. The attacker's token (and any legitimate
# user sessions) will be rejected on next use.
if ($PSCmdlet.ShouldProcess($UserPrincipalName, "Revoke all refresh tokens")) {
Revoke-MgUserSignInSession -UserId $User.Id
Write-Host " [+] All refresh tokens revoked (StsRefreshTokensValidFrom updated)" -ForegroundColor Green
}
$ContainmentTime = Get-Date -Format 'yyyy-MM-dd HH:mm:ss UTC' -AsUTC
Write-Host "`n Containment completed at: $ContainmentTime"
Write-Host " The account is now disabled and all active tokens are invalidated."
Write-Host " Proceed with investigation before re-enabling the account."
}
#endregion
#region ─── Incident Response: Investigation ─────────────────────────────────
function Invoke-DeviceCodeIR {
<#
.SYNOPSIS
Runs the full device code phishing investigation checklist for a
compromised user account.
.DESCRIPTION
Covers:
- Sign-in log review (device code event + follow-on access)
- OAuth application consent grants (new apps consented during compromise)
- Mailbox access review
- Inbox rule audit (common companion to device code compromise)
- Output an evidence summary for the incident record
Run AFTER Invoke-DeviceCodeContainment has disabled the account and
revoked tokens.
.PARAMETER UserPrincipalName
The compromised user's UPN.
.PARAMETER CompromiseWindowHours
How many hours before and after the suspected compromise to review.
Default: 24 (24 hours each direction = 48 hour window).
.EXAMPLE
Invoke-DeviceCodeIR -UserPrincipalName "[email protected]"
#>
param(
[Parameter(Mandatory = $true)]
[string]$UserPrincipalName,
[Parameter(Mandatory = $false)]
[int]$CompromiseWindowHours = 24
)
Write-Host "`n" + ("=" * 62) -ForegroundColor Cyan
Write-Host " DEVICE CODE PHISHING — INCIDENT INVESTIGATION" -ForegroundColor Cyan
Write-Host " User: $UserPrincipalName" -ForegroundColor Cyan
Write-Host ("=" * 62) -ForegroundColor Cyan
$User = Get-MgUser -Filter "userPrincipalName eq '$UserPrincipalName'" -Properties Id,DisplayName,AccountEnabled,LastPasswordChangeDateTime -ErrorAction Stop
Write-Host "`n[*] Account status:"
Write-Host " Display name : $($User.DisplayName)"
Write-Host " Account enabled: $($User.AccountEnabled) (should be False if containment ran)"
Write-Host " Last pwd change: $($User.LastPasswordChangeDateTime)"
# ── OAuth App Consent Grants ─────────────────────────────────────────────
# Attackers may consent to a malicious app during the compromise window
# to establish persistent access (consent survives token revocation).
Write-Host "`n[*] Checking OAuth application consents..." -ForegroundColor Cyan
$OAuthGrants = Get-MgUserOauth2PermissionGrant -UserId $User.Id -All
if ($OAuthGrants) {
Write-Host " $($OAuthGrants.Count) consent grant(s) found — review for unexpected applications:"
$OAuthGrants | Select-Object ClientId, Scope, ConsentType | Format-Table -AutoSize
Write-Host " [!] Revoke any grants to unrecognized apps:" -ForegroundColor Yellow
Write-Host " Remove-MgUserOauth2PermissionGrant -UserId `$User.Id -OAuth2PermissionGrantId <id>"
}
else {
Write-Host " No OAuth consent grants found." -ForegroundColor Gray
}
# ── Service Principal (App-Only) Grants ──────────────────────────────────
Write-Host "`n[*] Checking service principal permission grants on user object..." -ForegroundColor Cyan
$AppRoleAssignments = Get-MgUserAppRoleAssignment -UserId $User.Id -All
if ($AppRoleAssignments) {
Write-Host " $($AppRoleAssignments.Count) app role assignment(s) found:"
$AppRoleAssignments | Select-Object AppRoleId, PrincipalDisplayName, ResourceDisplayName | Format-Table -AutoSize
}
else {
Write-Host " No unexpected app role assignments found." -ForegroundColor Gray
}
# ── Inbox Rule Check ─────────────────────────────────────────────────────
# Device code phishing is frequently paired with malicious inbox rule
# creation. Requires an active Exchange Online connection.
Write-Host "`n[*] Checking inbox rules (requires Connect-ExchangeOnline)..." -ForegroundColor Cyan
if (Get-Command Get-InboxRule -ErrorAction SilentlyContinue) {
try {
$Rules = Get-InboxRule -Mailbox $UserPrincipalName -IncludeHidden -ErrorAction Stop |
Where-Object { $_.Name -ne 'Junk E-Mail Rule' }
if ($Rules) {
Write-Host " [!] $($Rules.Count) inbox rule(s) found — review for unauthorized rules:" -ForegroundColor Yellow
$Rules | Select-Object Name, Enabled, Description, MoveToFolder, MarkAsRead, ForwardTo, From | Format-List
}
else {
Write-Host " No inbox rules found (other than default Junk E-Mail Rule)." -ForegroundColor Gray
}
}
catch {
Write-Host " Could not retrieve inbox rules: $_" -ForegroundColor Gray
}
}
else {
Write-Host " Get-InboxRule not available — run Connect-ExchangeOnline and re-check manually." -ForegroundColor Yellow
Write-Host " Command: Get-InboxRule -Mailbox '$UserPrincipalName' -IncludeHidden | Where-Object { `$_.Name -ne 'Junk E-Mail Rule' }"
}
# ── Sign-In Log Summary ──────────────────────────────────────────────────
Write-Host "`n[*] Recent device code sign-in events for this user:" -ForegroundColor Cyan
Find-DeviceCodeSignIns -DaysBack ($CompromiseWindowHours / 24 + 1) -UserPrincipalName $UserPrincipalName
# ── Summary Output ───────────────────────────────────────────────────────
Write-Host "`n" + ("=" * 62)
Write-Host " INVESTIGATION CHECKLIST"
Write-Host ("=" * 62)
Write-Host " [x] Account disabled"
Write-Host " [x] Refresh tokens revoked"
Write-Host " [ ] Review OAuth consent grants above and revoke unexpected ones"
Write-Host " [ ] Review inbox rules above and remove unauthorized ones"
Write-Host " [ ] Review sign-in logs for lateral movement to shared resources"
Write-Host " [ ] Review email activity during compromise window"
Write-Host " [ ] Notify user and coordinate MFA re-enrollment"
Write-Host " [ ] Implement Conditional Access policy to block device code flow (see below)"
Write-Host " [ ] Re-enable account after investigation complete"
Write-Host ""
}
#endregion
#region ─── Remediation: Block Device Code Flow via Conditional Access ────────
function Get-DeviceCodeCAStatus {
<#
.SYNOPSIS
Checks whether a Conditional Access policy exists that blocks device code flow.
.DESCRIPTION
The single highest-impact control against device code phishing is a
Conditional Access policy that blocks the Device Code authentication flow.
Most corporate users have no legitimate need for device code sign-ins.
This function checks whether such a policy exists and is enabled.
If not, outputs the recommended policy configuration.
.EXAMPLE
Get-DeviceCodeCAStatus
#>
Write-Host "`n[*] Checking Conditional Access policies for device code flow restriction..." -ForegroundColor Cyan
$Policies = Get-MgIdentityConditionalAccessPolicy -All
# Look for policies that reference device code flow in their conditions
# Note: The Graph API represents authenticationFlows conditions as a property
# on the policy's Conditions object.
$DeviceCodePolicies = $Policies | Where-Object {
$_.Conditions.AuthenticationFlows -and
$_.Conditions.AuthenticationFlows.TransferMethods -contains 'deviceCodeFlow'
}
if ($DeviceCodePolicies) {
Write-Host " Found $($DeviceCodePolicies.Count) policy/policies referencing device code flow:"
$DeviceCodePolicies | Select-Object DisplayName, State, Id | Format-Table -AutoSize
$Blocking = $DeviceCodePolicies | Where-Object { $_.GrantControls.BuiltInControls -contains 'block' -and $_.State -eq 'enabled' }
if ($Blocking) {
Write-Host " [+] At least one enabled BLOCK policy exists for device code flow." -ForegroundColor Green
}
else {
Write-Host " [!] No enabled BLOCK policy found — device code flow may not be restricted." -ForegroundColor Yellow
}
}
else {
Write-Host " [!] No Conditional Access policy targeting device code flow found." -ForegroundColor Yellow
Write-Host ""
Write-Host " RECOMMENDED POLICY CONFIGURATION:"
Write-Host " ─────────────────────────────────────────────────────────────"
Write-Host " Name : Block Device Code Flow"
Write-Host " Users : All users (exclude break-glass accounts)"
Write-Host " Cloud apps : All cloud apps"
Write-Host " Conditions:"
Write-Host " Authentication flows → Device code flow → Yes"
Write-Host " Grant : Block access"
Write-Host " State : Start in Report-only, then enable after validation"
Write-Host ""
Write-Host " Portal path: Entra ID → Security → Conditional Access → New policy"
Write-Host " Microsoft docs: https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-block-authentication-flows"
Write-Host " ─────────────────────────────────────────────────────────────"
}
}
#endregion
#region ─── KQL Detection Queries (Microsoft Sentinel) ───────────────────────
<#
The following KQL queries can be added as Scheduled Analytic Rules in
Microsoft Sentinel, or run as one-off hunting queries in the Logs workspace.
═══════════════════════════════════════════════════════════════════════════════
QUERY 1 — Device Code Sign-In Followed by Token Use from Different IP
(Core detection for active device code phishing)
═══════════════════════════════════════════════════════════════════════════════
// Finds cases where a user authenticates via device code from one IP,
// then non-interactively accesses resources from a DIFFERENT IP within
// 10 minutes — the signature pattern of a successful device code phish.
let DeviceCodeWindow = 10m;
let DeviceCodeSignIns = SigninLogs
| where TimeGenerated > ago(7d)
| where AuthenticationProtocol == "deviceCode"
| where ResultType == 0 // successful sign-ins only
| project
DeviceCodeTime = TimeGenerated,
UserPrincipalName,
DeviceCodeIP = IPAddress,
AppDisplayName,
CorrelationId,
Location;
let FollowOnAccess = AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(7d)
| where ResultType == 0
| project
AccessTime = TimeGenerated,
UserPrincipalName,
AccessIP = IPAddress,
ResourceDisplayName,
CorrelationId = OriginalRequestId;
DeviceCodeSignIns
| join kind=inner (FollowOnAccess) on UserPrincipalName
| where AccessTime between (DeviceCodeTime .. (DeviceCodeTime + DeviceCodeWindow))
| where AccessIP != DeviceCodeIP // attacker's IP differs from the victim's
| project
DeviceCodeTime,
UserPrincipalName,
DeviceCodeIP,
AttackerIP = AccessIP,
App = AppDisplayName,
Resource = ResourceDisplayName,
Location
| order by DeviceCodeTime desc
═══════════════════════════════════════════════════════════════════════════════
QUERY 2 — All Device Code Sign-Ins (Broad Hunting)
(Run periodically to baseline and spot unusual patterns)
═══════════════════════════════════════════════════════════════════════════════
SigninLogs
| where TimeGenerated > ago(30d)
| where AuthenticationProtocol == "deviceCode"
| summarize
Count = count(),
SuccessCount = countif(ResultType == 0),
FailCount = countif(ResultType != 0),
Users = make_set(UserPrincipalName),
IPs = make_set(IPAddress)
by bin(TimeGenerated, 1d), AppDisplayName
| order by TimeGenerated desc
═══════════════════════════════════════════════════════════════════════════════
QUERY 3 — Device Code Sign-In from Hosting/Cloud ASNs
(Flags sign-ins from infrastructure commonly used by attackers)
═══════════════════════════════════════════════════════════════════════════════
// Attacker infrastructure tends to be developer cloud platforms, VPS
// providers, or residential proxy networks. Filter for ASN keywords that
// are unusual for your user population. Tune the ASN list for your org.
SigninLogs
| where TimeGenerated > ago(7d)
| where AuthenticationProtocol == "deviceCode"
| where ResultType == 0
| extend ASNInfo = tostring(parse_json(NetworkLocationDetails)[0].networkNames)
| where ASNInfo matches regex @"(?i)railway|digitalocean|linode|vultr|hetzner|psychz|ovh|choopa|m247|serverius|datacamp"
| project TimeGenerated, UserPrincipalName, IPAddress, ASNInfo, AppDisplayName, Location
| order by TimeGenerated desc
═══════════════════════════════════════════════════════════════════════════════
QUERY 4 — Inbox Rule Created Shortly After Device Code Sign-In
(Detects the common attacker pattern of chaining device code phishing
with inbox rule persistence in the same session)
═══════════════════════════════════════════════════════════════════════════════
let DeviceCodeSignIns = SigninLogs
| where TimeGenerated > ago(14d)
| where AuthenticationProtocol == "deviceCode"
| where ResultType == 0
| project DeviceCodeTime = TimeGenerated, UserPrincipalName;
let InboxRuleEvents = OfficeActivity
| where TimeGenerated > ago(14d)
| where Operation in ("New-InboxRule", "Set-InboxRule", "UpdateInboxRules")
| project RuleTime = TimeGenerated, UserId;
DeviceCodeSignIns
| join kind=inner (InboxRuleEvents) on $left.UserPrincipalName == $right.UserId
| where RuleTime between (DeviceCodeTime .. (DeviceCodeTime + 2h))
| project DeviceCodeTime, UserPrincipalName, RuleCreatedAt = RuleTime
| order by DeviceCodeTime desc
#>
#endregion
#region ─── Usage Reference ──────────────────────────────────────────────────
<#
QUICK REFERENCE — DEVICE CODE PHISHING RESPONSE
─── DETECTION (active investigation or periodic hunt) ────────────────────────
Connect-MgGraph -Scopes "AuditLog.Read.All","User.ReadWrite.All"
# Find all device code sign-ins in the last 7 days
Find-DeviceCodeSignIns -DaysBack 7
# Scope to a specific user
Find-DeviceCodeSignIns -DaysBack 7 -UserPrincipalName "[email protected]"
# Confirm attacker token use (run after identifying the device code event IP and time)
Find-DeviceCodeFollowOnAccess `
-UserPrincipalName "[email protected]" `
-DeviceCodeIP "203.0.113.1" `
-DeviceCodeTime "2026-03-18T14:41:52Z"
─── CONTAINMENT (run immediately on confirmed compromise) ────────────────────
# Preview what will be done
Invoke-DeviceCodeContainment -UserPrincipalName "[email protected]" -WhatIf
# Execute containment (disable account + revoke all tokens)
Invoke-DeviceCodeContainment -UserPrincipalName "[email protected]"
─── INVESTIGATION (run after containment) ────────────────────────────────────
# Also connect Exchange Online for inbox rule check
Connect-ExchangeOnline -UserPrincipalName [email protected]
# Run full investigation checklist
Invoke-DeviceCodeIR -UserPrincipalName "[email protected]"
─── PREVENTIVE CONTROLS ──────────────────────────────────────────────────────
# Check whether a CA policy blocking device code flow exists
Get-DeviceCodeCAStatus
# If no blocking policy exists, create one via:
# Entra ID → Security → Conditional Access → New policy
# Conditions → Authentication flows → Device code flow → Yes
# Grant → Block access
# State → Report-only first, then enable
─── RE-ENABLING THE ACCOUNT (after investigation is complete) ────────────────
Update-MgUser -UserId (Get-MgUser -Filter "userPrincipalName eq '[email protected]'").Id -AccountEnabled:$true
# Then require the user to re-enroll MFA.
#>
#endregion
