https://microsoft.com/devicelogin. The user sees a genuine Microsoft page, completes MFA, and the attacker receives the tokens. MFA does not stop this — the user really is authenticating, just on the attacker's behalf. Active campaigns: Storm-2372, EvilTokens, Tycoon 2FA, FlowerStorm. This page is the operational runbook; the strategic framing lives in Identity Hardening (Branch 3).
Confirm Device-Code activity exists in your tenant
SigninLogs
| where TimeGenerated > ago(30d)
| summarize Count=count() by AuthenticationProtocol, ClientAppUsed
| order by Count desc
Look for AuthenticationProtocol == "deviceCode". If the count is zero or near-zero, you can block the flow tenant-wide with minimal risk (see Mitigation). If it's non-zero, the rows tell you exactly which apps and clients to add to your exception list.
Baseline hunt query — 14-day window
SigninLogs
| where TimeGenerated > ago(14d)
| where AuthenticationProtocol =~ "deviceCode"
| extend
Success = toint(ResultType) == 0,
ErrorCode = tostring(Status.errorCode),
FailureReason = tostring(Status.failureReason),
Country = tostring(LocationDetails.countryOrRegion),
City = tostring(LocationDetails.city),
DeviceManaged = tostring(DeviceDetail.isManaged),
DeviceCompliant = tostring(DeviceDetail.isCompliant),
OS = tostring(DeviceDetail.operatingSystem),
Browser = tostring(DeviceDetail.browser)
| project
TimeGenerated,
Success,
UserPrincipalName,
AppDisplayName,
AppId,
ResourceDisplayName,
IPAddress,
Country,
City,
ClientAppUsed,
ConditionalAccessStatus,
RiskLevelAggregated,
RiskLevelDuringSignIn,
DeviceManaged,
DeviceCompliant,
OS,
Browser,
ErrorCode,
FailureReason,
CorrelationId
| order by TimeGenerated desc
Eight Sentinel analytics rules
1. Successful Device Code auth by any user High
SigninLogs
| where TimeGenerated > ago(1h)
| where AuthenticationProtocol =~ "deviceCode"
| where toint(ResultType) == 0
| extend
Country = tostring(LocationDetails.countryOrRegion),
DeviceManaged = tostring(DeviceDetail.isManaged),
DeviceCompliant = tostring(DeviceDetail.isCompliant)
| project
TimeGenerated,
UserPrincipalName,
AppDisplayName,
AppId,
ResourceDisplayName,
IPAddress,
Country,
ClientAppUsed,
ConditionalAccessStatus,
RiskLevelAggregated,
RiskLevelDuringSignIn,
DeviceManaged,
DeviceCompliant,
CorrelationId
2. Device Code auth outside approved users / apps / IPs High
AllowedDeviceCodeUsers, AllowedDeviceCodeApps, TrustedDeviceCodeIPs. Maintain these as your documented exception inventory. Every miss tells you something: unknown user, unknown app, or unknown IP.let AllowedUsers =
_GetWatchlist("AllowedDeviceCodeUsers")
| project AllowedUPN = tolower(SearchKey);
let AllowedApps =
_GetWatchlist("AllowedDeviceCodeApps")
| project AllowedAppId = tolower(SearchKey);
let TrustedIPs =
_GetWatchlist("TrustedDeviceCodeIPs")
| project TrustedIP = SearchKey;
SigninLogs
| where TimeGenerated > ago(1h)
| where AuthenticationProtocol =~ "deviceCode"
| where toint(ResultType) == 0
| extend
UPN = tolower(UserPrincipalName),
ClientAppId = tolower(AppId),
Country = tostring(LocationDetails.countryOrRegion)
| lookup kind=leftouter AllowedUsers on $left.UPN == $right.AllowedUPN
| lookup kind=leftouter AllowedApps on $left.ClientAppId == $right.AllowedAppId
| lookup kind=leftouter TrustedIPs on $left.IPAddress == $right.TrustedIP
| where isempty(AllowedUPN)
or isempty(AllowedAppId)
or isempty(TrustedIP)
| project
TimeGenerated,
UserPrincipalName,
AppDisplayName,
AppId,
ResourceDisplayName,
IPAddress,
Country,
ConditionalAccessStatus,
RiskLevelAggregated,
RiskLevelDuringSignIn,
Reason = strcat(
iff(isempty(AllowedUPN), "User not allowed; ", ""),
iff(isempty(AllowedAppId), "App not allowed; ", ""),
iff(isempty(TrustedIP), "IP not trusted; ", "")
),
CorrelationId
3. First-time Device Code usage for a user Medium / High
let HistoricalUsers =
SigninLogs
| where TimeGenerated between (ago(30d) .. ago(1d))
| where AuthenticationProtocol =~ "deviceCode"
| where toint(ResultType) == 0
| summarize by UserPrincipalName;
SigninLogs
| where TimeGenerated > ago(1d)
| where AuthenticationProtocol =~ "deviceCode"
| where toint(ResultType) == 0
| join kind=leftanti HistoricalUsers on UserPrincipalName
| extend Country = tostring(LocationDetails.countryOrRegion)
| project
TimeGenerated,
UserPrincipalName,
AppDisplayName,
AppId,
ResourceDisplayName,
IPAddress,
Country,
ConditionalAccessStatus,
RiskLevelAggregated,
CorrelationId
4. Device Code auth by a privileged user Critical
let PrivilegedUsers =
IdentityInfo
| where TimeGenerated > ago(14d)
| summarize arg_max(TimeGenerated, *) by AccountUPN
| where AssignedRoles has_any (
"Global Administrator",
"Privileged Role Administrator",
"Exchange Administrator",
"SharePoint Administrator",
"Security Administrator",
"Conditional Access Administrator",
"Application Administrator",
"Cloud Application Administrator"
)
| project PrivUPN = tolower(AccountUPN), AssignedRoles;
SigninLogs
| where TimeGenerated > ago(24h)
| where AuthenticationProtocol =~ "deviceCode"
| where toint(ResultType) == 0
| extend UPN = tolower(UserPrincipalName)
| join kind=inner PrivilegedUsers on $left.UPN == $right.PrivUPN
| extend Country = tostring(LocationDetails.countryOrRegion)
| project
TimeGenerated,
UserPrincipalName,
AssignedRoles,
AppDisplayName,
AppId,
IPAddress,
Country,
ConditionalAccessStatus,
RiskLevelAggregated,
RiskLevelDuringSignIn,
CorrelationId
5. Device Code followed by token use from a different IP / country Critical
let DeviceCodeSignins =
SigninLogs
| where TimeGenerated > ago(24h)
| where AuthenticationProtocol =~ "deviceCode"
| where toint(ResultType) == 0
| extend
DC_Time = TimeGenerated,
DC_IP = IPAddress,
DC_Country = tostring(LocationDetails.countryOrRegion),
UPN = tolower(UserPrincipalName)
| project UPN, UserPrincipalName, AppId, AppDisplayName, DC_Time, DC_IP, DC_Country, CorrelationId;
AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(24h)
| where toint(ResultType) == 0
| extend
UPN = tolower(UserPrincipalName),
NI_Time = TimeGenerated,
NI_IP = IPAddress,
NI_Country = tostring(LocationDetails.countryOrRegion)
| join kind=inner DeviceCodeSignins on UPN, AppId
| where NI_Time between (DC_Time .. DC_Time + 6h)
| where NI_IP != DC_IP or NI_Country != DC_Country
| project
DC_Time,
NI_Time,
UserPrincipalName,
AppDisplayName,
AppId,
DC_IP,
NI_IP,
DC_Country,
NI_Country,
ResourceDisplayName,
ConditionalAccessStatus,
CorrelationId
| order by NI_Time desc
6. Multiple users using Device Code from the same IP High
SigninLogs
| where TimeGenerated > ago(6h)
| where AuthenticationProtocol =~ "deviceCode"
| summarize
Users = dcount(UserPrincipalName),
UserList = make_set(UserPrincipalName, 25),
Apps = make_set(AppDisplayName, 25),
Successes = countif(toint(ResultType) == 0),
Failures = countif(toint(ResultType) != 0)
by IPAddress, bin(TimeGenerated, 30m)
| where Users >= 3 or Successes >= 2
| order by TimeGenerated desc
7. Device Code auth followed by suspicious mailbox actions Critical
let DeviceCodeSignins =
SigninLogs
| where TimeGenerated > ago(24h)
| where AuthenticationProtocol =~ "deviceCode"
| where toint(ResultType) == 0
| extend UPN = tolower(UserPrincipalName)
| project UPN, DC_Time = TimeGenerated, DC_IP = IPAddress, AppDisplayName, AppId;
OfficeActivity
| where TimeGenerated > ago(24h)
| where OfficeWorkload =~ "Exchange"
| where Operation in (
"MailItemsAccessed",
"Send",
"New-InboxRule",
"Set-InboxRule",
"Set-Mailbox",
"Add-MailboxPermission"
)
| extend UPN = tolower(UserId)
| join kind=inner DeviceCodeSignins on UPN
| where TimeGenerated between (DC_Time .. DC_Time + 12h)
| project
TimeGenerated,
UserId,
Operation,
ClientIP,
AppDisplayName,
AppId,
DC_Time,
DC_IP,
OfficeObjectId,
Parameters
| order by TimeGenerated desc
8. Enrichment — tag known Device Code apps Medium (enrichment)
let KnownDeviceCodeApps = datatable(AppId:string, AppName:string)
[
"04b07795-8ddb-461a-bbee-02f9e1bf7b46", "Microsoft Azure CLI",
"1950a258-227b-4e31-a9cf-717495945fc2", "Microsoft Azure PowerShell",
"14d82eec-204b-4c2f-b7e8-296a70dab67e", "Microsoft Graph Command Line Tools",
"31359c7f-bd7e-475c-86db-fdb8c937548e", "PnP Management Shell",
"d3590ed6-52b3-4102-aeff-aad2292ab01c", "Microsoft Office"
];
SigninLogs
| where TimeGenerated > ago(14d)
| where AuthenticationProtocol =~ "deviceCode"
| extend ClientAppId = tolower(AppId)
| join kind=leftouter KnownDeviceCodeApps on $left.ClientAppId == $right.AppId
| project
TimeGenerated,
UserPrincipalName,
AppDisplayName,
KnownAppName = AppName,
AppId,
IPAddress,
ResourceDisplayName,
ResultType,
ConditionalAccessStatus
Block the flow with one Conditional Access policy
BLOCK — Device Code Authentication. Naming convention matters here — future you and the next admin need to find this policy fast during an incident.ConditionalAccessStatus = reportOnlyFailure AND the policy name matches. Each match is either a legitimate use case (add to exception group with a ticket) or a phishing attempt (you now have your first detection). Then flip to On.Additional hardening (deploy in parallel)
- Disable or tightly control end-user consent to applications — Enterprise applications → Consent and permissions; require admin approval for OAuth app consent on sensitive scopes.
- Remove unused Enterprise Applications and service principals — the attacker may bring their own AppId, but a tenant with hundreds of stale apps is also a hunting ground.
- Review and minimize delegated permissions:
Mail.Read,Mail.ReadWrite,Files.Read.All,offline_access. - Require compliant or hybrid-joined devices for sensitive apps (M365, finance, HR).
- Require phishing-resistant MFA for admins (FIDO2 / Windows Hello). See Identity Hardening for the FIDO2 rollout pattern.
- Use Privileged Identity Management (PIM) for admin roles — no standing admin access.
- Replace user-based automation with managed identities, service principals with certificates, or workload identities. User accounts running automation are the easiest exception-list expansion mistake.
- Train users on one rule: never enter a device code unless you personally initiated the sign-in. Microsoft will never call, email, or Teams-message a code for you to enter.
For normal employees, Device Code auth is unnecessary by default
1. Block Device Code flow tenant-wide.
2. Allow only documented exceptions.
3. Alert on every successful use.
4. Review exceptions quarterly.
- Block device-code flow by default, allow only by app + IP exception. (This page.)
- Block user OAuth consent; admin-only consent for risky scopes.
- Shorten access + refresh-token lifetimes; sign-in frequency controls for sensitive apps.
- CA uses risk + behavior + device compliance, not just success/failure.
- Phishing-resistant MFA (FIDO2 / passkeys) for privileged roles.
Related playbooks
Identity Hardening
Strategic framing for all six branches of the identity attack tree. Device Code is Branch 3; the same tenant probably has open Branches 4 (OAuth consent) and 5 (token theft / PRT). One CA policy at a time is tactical; the framework is what closes the program-level gap.
Identity Breach Response
Full T+0—Day-30 IR timeline. This page's Step 5 is the Device-Code-specific subset; that playbook is the broader runbook covering the same containment pattern for AiTM, OAuth consent abuse, NHI compromise, and PRT persistence.