Device Code Abuse

M365 / Entra ID Detection & Response Runbook — MFA does not stop this. The victim authenticates to a real Microsoft page, the attacker receives the tokens. One Conditional Access policy closes the door; eight Sentinel hunts find the cases that slip through.

8
KQL alert rules
1
CA policy kills the flow
90 days
refresh token survives password reset
Free
all controls, config-only
Why this matters. The OAuth 2.0 Device Authorization Grant flow is legitimate for limited-input devices (Teams Rooms, CLI tools, kiosks). Attackers abuse it by initiating the flow themselves, then directing a victim to enter the attacker-issued code at the real 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).
Incident in progress? The response steps are on Device Code Abuse Response. This page is how you get ready.
Step 1

Confirm Device-Code activity exists in your tenant

Before tuning detections, find out whether anyone in your tenant actually uses Device Code at all. In most environments the legitimate volume is near zero — which makes every successful event high-signal.
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.

Step 2

Baseline hunt query — 14-day window

Pulls every Device Code sign-in with the fields you actually need for triage: success/failure, geo, device posture, CA evaluation result, and correlation ID. Use this as the starting point for any investigation.
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
Step 3 — Alert rules

Eight Sentinel analytics rules

Build each as a separate Sentinel analytics rule with the suggested severity. Use the watchlist-based rule (Alert 2) to suppress your documented exceptions; everything else fires on raw signal. The mailbox-action and IP-divergence rules (5, 7) are the highest-fidelity post-compromise indicators.

1. Successful Device Code auth by any user High

Baseline alert. Fires on every successful Device Code sign-in. Tune down only after watchlists (Alert 2) are in place.
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

Same as Alert 1 but suppressed by three Sentinel watchlists: 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

Anti-joins today's successes against 30 days of history. Catches phishing before it becomes baseline. Raise severity to High for any user in a sensitive role.
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

Joins against IdentityInfo for any account holding a Tier-0 / Tier-1 admin role. Privileged accounts should never legitimately use Device Code — if your CA policy is correct, this rule should be silent.
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

The strongest single indicator. Victim authenticates from one location; attacker uses the tokens elsewhere within hours. Joins SigninLogs (the auth event) with AADNonInteractiveUserSignInLogs (the token use) on UPN+AppId within a 6h window.
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

Campaign detector. An attacker running Storm-2372-style mass phishing will see multiple victims redeem codes from the attacker's IP. Threshold: 3+ users or 2+ successes from one IP in 30 minutes.
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

Joins Device Code sign-ins to OfficeActivity within a 12h window for the high-signal mailbox operations attackers run post-compromise: bulk read, send, inbox rule create/modify, mailbox delegate add.
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)

Not a standalone alert — use this as a lookup in other queries. Known apps that legitimately use Device Code (Azure CLI, Graph CLI, PnP Shell). AppId matching alone is not sufficient signal: attackers can target these same AppIds, and a Device Code auth to "Microsoft Office" is suspicious if the user works only from managed desktops.
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
Step 4 — Mitigation

Block the flow with one Conditional Access policy

Detection is the fallback. The primary control is to remove the attack surface entirely: a Conditional Access policy blocking the Device Authorization Grant flow tenant-wide, with a tightly-scoped exception group for legitimate use cases. Config-only, no licensing required.
Start in Report-only mode for at least one full business week. Then review which sign-ins would have been blocked. If the only matches are the alerts you wanted to catch, enable. If legitimate use cases surface (Teams Rooms, automation, kiosk accounts), add them to the exception group with documentation — not by softening the policy.
1. Entra ID → Protection → Conditional Access → New policy
Name: BLOCK — Device Code Authentication. Naming convention matters here — future you and the next admin need to find this policy fast during an incident.
2. Users → Include all users; Exclude tightly-controlled exception group only
Exception group membership is auditable. Members typically: dedicated Teams Rooms accounts, approved kiosk / shared-device accounts, specific admin automation users with a documented business case. Break-glass accounts should NOT be in this group — they should use phishing-resistant auth, not Device Code.
3. Target resources → All cloud apps
Do not scope by app. Device Code is a flow restriction, not an app restriction; scoping by app leaves gaps.
4. Conditions → Authentication flows → Device code flow
This is the specific condition that targets the OAuth Device Authorization Grant flow without affecting other auth paths.
5. Grant → Block access
No conditional grant. The flow is either allowed (exception group) or it is not.
6. Enable policy → Report-only → review for one week → On
Review impact in the Sign-in logs — filter where 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.
If you cannot fully block. Restrict to a tightly-scoped exception group with one or more of these constraints: approved corporate IP ranges only, compliant or hybrid-joined devices only, named accounts only. Then alert on every exception use (Alert 1 above, scoped to the exception group). Exceptions without alerting is just an open door with a sign on it.
Blocking the flow is not the whole story — audit for the FOCI resource-exclusion bypass. A documented Conditional Access behavior lets a narrow set of low-privilege Graph scopes — including ones reachable through Family-of-Client-IDs (FOCI) first-party client IDs — slip past an all-resources ("All cloud apps") policy whenever that policy carries even one resource exclusion. The block policy above is safe as written — its exclusion is on users (the exception group), not on resources — but any other CA policy in the tenant that excludes a resource can leave a low-privilege Graph path open for a stolen token to ride. Inventory every CA policy that carries a resource exclusion and confirm none unintentionally exposes Microsoft Graph; where an exclusion is genuinely needed, prefer targeting Graph explicitly as a named resource over relying on "All cloud apps" minus an exclusion. Microsoft is tightening this behavior with enforcement changes rolling through mid-2026 — verify rather than assume your tenant is already covered.

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.
Recommended policy position

For normal employees, Device Code auth is unnecessary by default

Most enterprise workforces have zero legitimate use case for Device Code. The legitimate cases (Teams Rooms, dedicated kiosk accounts, specific automation) are a named, auditable group — not a default-enabled tenant capability.
The four-line stance to put in your identity policy document:
1. Block Device Code flow tenant-wide.
2. Allow only documented exceptions.
3. Alert on every successful use.
4. Review exceptions quarterly.
Where this fits in the broader Conditional Access evolution. Okta / Huntress / Microsoft consensus on CA maturity is a five-step progression. Blocking Device Code is step 1 — the cheapest single config change with the largest single drop in attack surface. The full path:
  1. Block device-code flow by default, allow only by app + IP exception. (This page.)
  2. Block user OAuth consent; admin-only consent for risky scopes.
  3. Shorten access + refresh-token lifetimes; sign-in frequency controls for sensitive apps.
  4. CA uses risk + behavior + device compliance, not just success/failure.
  5. Phishing-resistant MFA (FIDO2 / passkeys) for privileged roles.
Branch-by-branch mapping with pricing + effort estimates: Identity Hardening — Conditional Access Evolution Path →