The victim never types a password into a fake page. The evidence is in which flow was used, where the tokens went, and what the attacker did next.
Here is an illustrative case. A finance user gets an email about an invoice. The link opens a document preview with a "Verify identity" button, which sends them to microsoft.com/devicelogin. A code is already on their clipboard. They paste it, sign in, approve the MFA prompt, and land on what looks like DocuSign. Twenty minutes later a new device is registered to their account, and the next week an inbox rule starts hiding replies about a wire transfer.
Nothing in that chain asked for a password on a fake site, so detections built around password capture, such as "user entered credentials on an unknown page", have nothing to see: the sign-in page was Microsoft's. The lure, the redirects and what the attacker does next can still be detected, and that is where this post looks. This is device code phishing. Microsoft documented a campaign in April 2026 that generated codes on demand, so a code was always fresh when the victim used it.
This post covers how the attack uses the flow, the sign-in fields that record it, three queries to find it and what follows it, and how to block the flow without breaking the devices that need it.
How the flow is abused
Device code flow exists for devices that can't show a sign-in page: conference room systems, smart displays, command-line tools. The device asks Microsoft for a code, shows it to the user, and polls until the user enters it on another device and signs in. Then Microsoft gives the polling device the tokens.
The attacker becomes the polling device. In the campaign Microsoft described, the lure page called the attacker's backend when the victim arrived, which requested a fresh code from Microsoft's device authorization endpoint and copied it to the victim's clipboard. Generating the code at that moment matters: the code is valid for 15 minutes, and the clock started when the victim clicked, not when the email was sent. The page then polled the attacker's server every 3 to 5 seconds until the victim finished signing in.
Everything the victim did was legitimate, including the MFA. What changed is where the tokens went. Microsoft then saw two patterns: some victims had a new device registered within about 10 minutes, a step toward the Primary Refresh Token the attacker wanted for persistence; for others, the actor waited hours, mapped the organization through Microsoft Graph, and created inbox rules and searched mail for invoices and wire details.
The fields that record it
Two sign-in log columns carry the flow, and you need both.
AuthenticationProtocol records the protocol or grant used for that sign-in. The AADNonInteractiveUserSignInLogs reference lists its values: none, oAuth2, ropc, wsFederation, saml20 and deviceCode. The initial device code sign-in carries deviceCode.
OriginalTransferMethod records the method that started the session, carried through all later requests. Microsoft calls this protocol tracking: a session that began with device code flow stays tracked on later token refreshes, even when the current event no longer shows device code as its protocol. Microsoft's Teams devices guidance is explicit that relying only on the protocol field when investigating these sessions is the wrong practice. Token refreshes are recorded as non-interactive sign-ins, so the attacker's later activity on the session lands in the non-interactive table: query both.
Inventory the device code flow you already have
Before you hunt, find out who uses the flow legitimately. Run this over 30 days:
// Sources: SigninLogs and AADNonInteractiveUserSignInLogs
// (Entra ID diagnostic settings to Log Analytics or Sentinel)
// Question: who uses device code flow, for which apps, from which OS, and how often?
// The output is your exception list for the block policy and your baseline for detection.
union
(SigninLogs
| where TimeGenerated > ago(30d)
| where AuthenticationProtocol == "deviceCode" or OriginalTransferMethod =~ "deviceCodeFlow"
| extend ClientOS = tostring(DeviceDetail.operatingSystem)),
(AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d)
| where AuthenticationProtocol == "deviceCode" or OriginalTransferMethod =~ "deviceCodeFlow"
| extend ClientOS = tostring(parse_json(DeviceDetail).operatingSystem))
| summarize SignIns = count(), Successes = countif(ResultType == "0"),
Users = dcount(UserPrincipalName), FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated)
by AppDisplayName, ResourceDisplayName, ClientOS
| sort by Users desc
// Expect conference room and Teams devices, admins using CLI tools, and little else.
// DeviceDetail is dynamic in SigninLogs and a string in the non-interactive table. A plain union
// splits it into two typed columns, so ClientOS is extracted per table before the union.On most tenants the list is short: Teams Rooms and Android meeting devices, a few administrators using command-line tools, perhaps signage. Anything you can't attribute to a device type or a named team is the first thing to investigate, and everything you can attribute is a candidate for a named exception rather than a reason to leave the flow open for everyone. Observed use is not approved use: each exception needs an owner who confirms the flow is still needed.
Detect a device code sign-in that doesn't fit
The first detection flags a successful device code sign-in for a user who hasn't used the flow in the previous 30 days:
// Source: SigninLogs, with AADNonInteractiveUserSignInLogs for the baseline
// Detection: first observed successful device code sign-in for a user in the past 30 days
// Technique: T1528 Steal Application Access Token, by device code phishing
// Needs 31 days of both tables in the workspace; with less retention the baseline is shorter than it looks.
let Lookback = 2h; // overlaps the hourly schedule so records that arrive late are still evaluated
let History = union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated between (ago(31d) .. ago(Lookback))
| where ResultType == "0"
| where AuthenticationProtocol == "deviceCode" or OriginalTransferMethod =~ "deviceCodeFlow"
| where isnotempty(UserId)
| distinct UserId;
SigninLogs
| where TimeGenerated > ago(Lookback)
| where AuthenticationProtocol == "deviceCode" and ResultType == "0"
| where isnotempty(UserId) and UserId !in (History)
| extend Country = tostring(LocationDetails.countryOrRegion)
| project TimeGenerated, UserId, UserPrincipalName, AppDisplayName, ResourceDisplayName,
IPAddress, Country, UserAgent, CorrelationId
// Run hourly; the two-hour lookback means one sign-in can appear in two runs, so group alerts
// by CorrelationId in the rule. Prior failed attempts don't suppress a first success.
// IPAddress is where the tokens were issued: in phishing it's the attacker's poller, not the user.Two kinds of noise are worth expecting. A new meeting room device signed in by a facilities account will fire once, and should become a named exception the same day. An administrator trying a command-line tool for the first time will fire too, and the answer there is a conversation, not an exception: the person is real, and the question is whether the tool needs device code flow at all. Everything else, and especially a user in finance, executive support or payroll, deserves the full investigation, because those are the roles Microsoft saw the actor select for follow-up.
This query only finds users new to the flow. A device code phish against an account that already uses device code flow legitimately, such as an administrator with a CLI habit, won't fire it. Cover those accounts with a separate rule, for example a device code sign-in from an address or country that account hasn't used.
The last comment is the investigative point. In a genuine device code sign-in, the address belongs to the device that polled, which is usually the user's own network or a known meeting room. In a phish it belongs to the attacker's infrastructure, often a cloud host, so compare it with the user's other sign-ins in the same hour. An unfamiliar address is a lead, not proof: VPNs, mobile networks and proxies produce them too, so check its ownership, the user agent and the app against the timeline before calling it the attacker's.
Microsoft's write-up adds a second signal for Defender XDR customers: error code 50199 followed by success for the same correlation ID, which suggests a pause while the user entered a code. It's a useful corroborating query in advanced hunting, where Microsoft published it against EntraIdSigninEvents.
Detect the device registration that follows
The second detection looks for the persistence step: a device registered for the same user shortly after a device code sign-in.
// Sources: SigninLogs and AuditLogs
// Detection: device registered within 60 minutes of a device code sign-in for the same user
// Technique: T1098.005 Device Registration, after device code phishing
// Joined on the user's object ID: UPN casing differs between the two logs, and a case-sensitive
// join on UPN silently matches nothing.
// Schedule every 15 minutes. The 3-hour lookback covers the 60-minute window plus late-arriving
// records; overlapping runs return the same pair again, so deduplicate alerts on DeviceId and SigninTime.
let Lookback = 3h;
let DeviceCode = SigninLogs
| where TimeGenerated > ago(Lookback)
| where AuthenticationProtocol == "deviceCode" and ResultType == "0"
| project SigninTime = TimeGenerated, UserId, UserPrincipalName,
SigninIP = IPAddress, AppDisplayName;
AuditLogs
| where TimeGenerated > ago(Lookback)
| where OperationName == "Register device" and Result =~ "success"
| mv-expand Target = TargetResources
| where tostring(Target.type) == "Device"
| extend UserId = tostring(InitiatedBy.user.id),
RegistrationIP = tostring(InitiatedBy.user.ipAddress),
DeviceId = tostring(Target.id),
DeviceName = tostring(Target.displayName),
RegisteredAt = ActivityDateTime
| join kind=inner DeviceCode on UserId
| extend MinutesAfterSignin = datetime_diff("minute", RegisteredAt, SigninTime)
| where MinutesAfterSignin between (-5 .. 60)
| project SigninTime, RegisteredAt, MinutesAfterSignin, UserPrincipalName, AppDisplayName,
SigninIP, RegistrationIP, DeviceId, DeviceName
// Microsoft saw registration within about 10 minutes; 60 leaves room for slower operators.
// The -5 tolerates small differences between the two services' event timestamps. It isn't about
// ingestion delay, which the lookback covers.
// A hit is a strong lead, not proof: onboarding and reprovisioning a user's own device can match too.An empty result from this query only means something once you know it can return a row. Check that Register device events in your tenant carry the user's object ID in InitiatedBy.user.id, and that the join finds pairs when the time filter is removed:
// Proving the join can match: device code sign-ins and device registrations by the same user,
// any time apart, over 30 days. Zero rows means either the keys don't line up or no account
// did both in the period; the raw-event check below tells you which.
let DeviceCode = SigninLogs
| where TimeGenerated > ago(30d)
| where AuthenticationProtocol == "deviceCode" and ResultType == "0"
| summarize LastDeviceCodeSignin = max(TimeGenerated) by UserId, UserPrincipalName;
AuditLogs
| where TimeGenerated > ago(30d)
| where OperationName == "Register device"
| extend UserId = tostring(InitiatedBy.user.id)
| summarize Registrations = count(), LastRegistration = max(ActivityDateTime) by UserId
| join kind=inner DeviceCode on UserId
// Meeting room accounts that register and use device code should appear here. If none do,
// read one raw Register device event and adjust the extend to where your tenant puts the user.Investigate a hit urgently, and contain on the corroborating evidence: an unfamiliar registration address, a device name the user doesn't recognize, the user denying they set anything up. Registration alone doesn't issue a Primary Refresh Token; Microsoft's PRT documentation describes the PRT being issued when the user authenticates on a registered device through a broker. A registration the attacker controls puts that within reach, so if the evidence points to the attacker, revoking the user's sessions isn't enough on its own: disable or delete the device object too, then check for inbox rules, new MFA methods and consent grants. Access tokens already issued stay usable until they expire, which Microsoft's access token documentation puts at a random 60 to 90 minutes by default, with exceptions; that is why temporarily disabling the account is part of the response.
When a detection fires: scope it in four queries
A hit gives you one user and one address. The four queries below turn that into a scope: who else, what the session touched, whether mail was tampered with, and whether the organization was mapped. Set the two values once and run them in order.
Who else got tokens at the same address. Phishing kits poll from shared infrastructure, so one victim's address often appears for others:
// Sources: SigninLogs and AADNonInteractiveUserSignInLogs
// Question: which other accounts received tokens at the attacker's polling address?
let AttackerIP = "<IPAddress from the detection>";
let Since = ago(14d);
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > Since and IPAddress == AttackerIP
| summarize FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated),
Successes = countif(ResultType == "0"), Protocols = make_set(AuthenticationProtocol, 10)
by UserPrincipalName
| sort by FirstSeen asc
// Accounts with a success here are potentially related, not confirmed victims: shared hosting,
// proxies and NAT put unrelated users behind one address. Check each against its own history.Which resources the device-code-derived session requested tokens for. The attacker's token refreshes stay tied to the device code session, so OriginalTransferMethod follows them across applications. This shows token requests, not data access; what was read or changed needs the workloads' own logs:
// Source: AADNonInteractiveUserSignInLogs
// Question: which resources did the device-code-derived session request tokens for, from where,
// and did requests succeed or fail before and after containment?
let VictimId = "<UserId from the detection>";
let IncidentStart = datetime(<time of the device code sign-in>);
let ContainedAt = datetime(<time you revoked sessions>);
AADNonInteractiveUserSignInLogs
| where TimeGenerated > IncidentStart - 1h
| where UserId == VictimId and OriginalTransferMethod =~ "deviceCodeFlow"
| extend Outcome = iff(ResultType == "0", "Success", strcat("Failed ", ResultType)),
Phase = iff(TimeGenerated < ContainedAt, "Before containment", "After containment")
| summarize Events = count(), FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated),
IPs = make_set(IPAddress, 20), Sessions = make_set(SessionId, 10),
Errors = make_set(ResultDescription, 5)
by Phase, Outcome, AppDisplayName, ResourceDisplayName
| sort by Phase asc, FirstSeen asc
// Failures after containment are expected: rejected refreshes are revocation working.
// A success after containment needs investigating in context, and still doesn't show what was accessed.Whether mail rules were changed. Microsoft saw inbox rules created to hide or redirect mail for selected victims:
// Source: OfficeActivity (Microsoft 365 connector, Exchange workload)
// Question: did anyone create or change mailbox rules on the victim's mailbox, or did the victim's
// identity change rules anywhere, after the sign-in?
let Victim = "<UserPrincipalName from the detection>";
OfficeActivity
| where TimeGenerated > ago(14d)
| where OfficeWorkload == "Exchange"
| where Operation in ("New-InboxRule", "Set-InboxRule", "Enable-InboxRule", "UpdateInboxRules", "Set-Mailbox")
| where UserId =~ Victim or MailboxOwnerUPN =~ Victim or Parameters has Victim
| project TimeGenerated, Operation, Actor = UserId, MailboxOwnerUPN, ResultStatus, ClientIP, Parameters
// UserId is who acted; MailboxOwnerUPN and Parameters catch changes made to this mailbox by another identity.
// Look for rules that move mail to RSS Feeds, Archive or Deleted Items, or names made only of punctuation.Whether the organization was mapped. If you collect Microsoft Graph activity logs, the reconnaissance Microsoft described shows as directory reads from the victim's session:
// Source: MicrosoftGraphActivityLogs (Entra ID diagnostic settings; requires P1 or P2)
// Question: did the victim's identity read users, groups or roles after the sign-in, and did it work?
let VictimId = "<user object ID of the victim>";
MicrosoftGraphActivityLogs
| where TimeGenerated > ago(14d)
| where UserId == VictimId and RequestMethod == "GET"
| extend Path = tostring(parse_url(RequestUri).Path)
| where Path matches regex @"^/(v1\.0|beta)/(users|groups|directoryRoles|organization)(/|$)"
| extend Outcome = iff(ResponseStatusCode between (200 .. 299), "Succeeded", "Denied or failed")
| summarize Requests = count(), FirstSeen = min(TimeGenerated), IPs = make_set(IPAddress, 10),
Sessions = make_set(SessionId, 5), Examples = make_set(Path, 5)
by AppId, Outcome, bin(TimeGenerated, 1h)
| sort by FirstSeen asc
// Compare with the user's normal Graph activity before calling it reconnaissance: legitimate
// apps make directory reads too. A $batch request appears as one row, and its 200 doesn't mean
// every operation inside it succeeded.Graph activity logs can't be turned on after the fact. If you don't collect them, the fourth question has no answer for an incident that has already happened, which is a reason to enable them now.
Block the flow without breaking meeting rooms
Detection is the fallback. Microsoft's own guidance is to block device code flow wherever possible with the Conditional Access authentication flows condition, and to allow it only for documented cases. Two details decide whether the block works.
First, check whether Microsoft already created a policy for you. Microsoft-managed policies include Block device code flow. It's created in report-only mode, and Microsoft says it enables these policies no less than 30 days after they're introduced if left in report-only, with notice two weeks before, though in some cases faster, which the notifications and the policy details will say. Find it under Conditional Access policies with Microsoft in the Created by column, and confirm its state rather than assuming. A managed policy allows simple changes only, such as excluding users or changing its state; for anything more, Microsoft suggests duplicating it and configuring the copy as your own policy.
Second, exclude what genuinely needs the flow, and the Device Registration Service. Microsoft's Teams guidance says the Device Registration Service exclusion is required, or device registration through device code flow breaks. That exclusion deliberately leaves device registration outside this policy, so restrict who can register devices separately and keep the registration detection above running. Exclude emergency access accounts and a named exception group built from the inventory query, nothing broader.
Then measure the effect in report-only mode before switching it on:
// Sources: SigninLogs and AADNonInteractiveUserSignInLogs
// Question: which sign-ins would the device code block policy have stopped?
// Run after the policy has been in report-only mode for at least a week.
let PolicyId = "<object ID of the block policy>";
union
(SigninLogs | where TimeGenerated > ago(7d)
| extend CAP = ConditionalAccessPolicies, ClientOS = tostring(DeviceDetail.operatingSystem)),
(AADNonInteractiveUserSignInLogs | where TimeGenerated > ago(7d)
| extend CAP = todynamic(ConditionalAccessPolicies),
ClientOS = tostring(parse_json(DeviceDetail).operatingSystem))
| mv-expand Policy = CAP
| where tostring(Policy.id) == PolicyId and tostring(Policy.result) == "reportOnlyFailure"
| summarize WouldBlock = count(), Users = make_set(UserPrincipalName, 50)
by Type, AppDisplayName, ClientOS
// Each row summarizes sign-ins the policy would have blocked, interactive and non-interactive,
// so derived device code sessions are counted too. Each needs an exception or an owner who accepts the block.The same detection in Splunk and Sigma
If Entra sign-in logs reach Splunk through an Event Hub and the Microsoft cloud services add-on, the Graph property names come through under properties. Field names vary by add-on version, so check yours against one known device code sign-in first.
index=azure sourcetype="azure:monitor:aad" category=SignInLogs properties.authenticationProtocol=deviceCode
properties.status.errorCode=0 earliest=-1h
| rename properties.userPrincipalName AS user, properties.ipAddress AS src_ip,
properties.appDisplayName AS app
| search NOT [ search index=azure sourcetype="azure:monitor:aad" properties.authenticationProtocol=deviceCode
earliest=-31d latest=-1h | rename properties.userPrincipalName AS user | dedup user | fields user ]
| table _time user app src_ip properties.location.countryOrRegionAnd as Sigma, for anything you convert from. Field names in Sigma's Azure sign-in rules depend on your backend's pipeline, so map AuthenticationProtocol before you deploy:
title: Successful Entra ID Device Code Sign-In
status: experimental
description: Broad detection of successful device code sign-ins, to be compared with the documented exception list. It does not implement the first-time-user baseline of the KQL rule.
logsource:
product: azure
service: signinlogs
detection:
selection:
AuthenticationProtocol: deviceCode
ResultType: 0
condition: selection
falsepositives:
- Teams Rooms and Android meeting devices
- Administrators using command-line tools that sign in with a device code
level: medium
tags:
- attack.credential-access
- attack.t1528
- attack.initial-access
- attack.t1566.002Where to read more
- Microsoft Security blog, the AI-enabled device code phishing campaign write-up of 6 April 2026, with Microsoft's advanced hunting queries.
- Microsoft Learn, "Conditional Access: Authentication flows", "Block authentication flows with Conditional Access policy", and "Restrict device code flow for Microsoft Teams devices".
- Microsoft Learn, "Microsoft-managed policies", "Primary Refresh Token", "Access tokens in the Microsoft identity platform", and the SigninLogs, AADNonInteractiveUserSignInLogs, AuditLogs, OfficeActivity and MicrosoftGraphActivityLogs table references.
- MITRE ATT&CK, T1528 Steal Application Access Token, T1566.002 Spearphishing Link and T1098.005 Device Registration.
- Our free KQL reference for the sign-in log patterns these queries build on.
What to do this week
- Run the inventory query over 30 days. Name an owner for every app and device type that uses device code flow.
- Find the Microsoft-managed "Block device code flow" policy and record its state. If it's report-only, decide when it goes on rather than waiting for Microsoft to decide.
- Build the exception group from the inventory, exclude the Device Registration Service, and run the report-only query for a week.
- Deploy the two detections as scheduled rules: new device code users hourly, device code followed by device registration every 15 minutes, both with the lookbacks shown. Save the four scoping queries next to them, so the first responder runs them in order.
- Test it yourself. Sign in with
az login --use-device-codefrom a test account and confirm the sign-in appears with the protocol you expect, then that the policy blocks it once enabled. Test the detections against the cases that should and shouldn't fire: a first-time user, a user whose only earlier attempt failed, failed refreshes after a revoke, a legitimate device registration, and an unrelated user behind the same address.