Over the last few months the SOC I work in has seen a sharp rise in device code phishing. It’s gone from something we’d read about occasionally to something we handle numerous times weekly, and the pattern is consistent enough that I thought it would be good to document in my blog. So here’s what we’re seeing, how the attack actually works, and what stops it.
Most phishing awareness course teaches the same predictable check. Look at the URL – make sure you’re really on Microsoft’s site and not a fake login page. Device code phishing works because the link is real – the site really is Microsoft, and there’s no fake login page anywhere in the attack. The user signs in properly, completes MFA, and hands an attacker the tokens to their account without ever knowing it happened.
In recent published statistics, it is seen that OAuth abuse is more than doubling year over year, from 4.8% to 10.1% of identity threats. It’s spreading because it’s easy, it beats MFA, and it needs no attacker infrastructure.
What device code authentication is
The flow exists for devices that can’t reasonably show a login prompt. Smart TVs, streaming boxes, Teams Rooms panels, command line tools. If you’ve signed into Netflix on a television, you’ve used it.
An application asks Microsoft for a device code.
Microsoft returns a short, temporary code.
The user goes to a Microsoft login page on their phone or laptop.
The user enters the code and signs in, MFA included.
Microsoft issues tokens to the application that requested the code.
The genuine microsoft.com/devicelogin page.
How attackers use it
The chain is short:
Attacker requests a device code from Microsoft.
Attacker sends that code to the victim with a plausible reason to enter it. Usually an email (sometimes over Teams or a phone call).
Victim goes to the real Microsoft page, enters the provided code, completes MFA.
Microsoft issues tokens to the attacker’s waiting application.
Attacker queries Microsoft Graph and maps the tenant.
Attacker registers a device in Entra ID.
That device gets issued a Primary Refresh Token.
Attacker opens a fully authenticated session as the victim.
Example, the email lure as the victim sees it:
The codes expire quickly (about 15 minutes), so lures almost always carry urgency.
Tokens and their importance
Token
Lifetime
What it gets you
Access token
About an hour
Access to one service
Refresh token
Days to months
New access tokens without signing in again
Primary Refresh Token (PRT)
Long, bound to a registered device
Single sign-on across the Microsoft estate
The PRT is the one that matters the most. It’s tied to a device registered in your tenant and it gives seamless access across Outlook, SharePoint, OneDrive, Teams and the Azure portal. This is why you will almost always see a device registration event – its the most common pathway to the PRT. At that point the attacker isn’t borrowing a session, they’re operating as a trusted device that belongs to your user. It’s also why resetting the password doesn’t fix it. Tokens already issued carry on working.
How to prevent it
Block the device code flow in Conditional Access. This is the real fix. Build a policy targeting the device code grant and block it for everyone who doesn’t need it, which in most places is nearly everyone. Baseline your sign-in logs first so you know who genuinely uses it. Scope your exclusions to those and block the rest.
Restrict device registration. Require MFA to register a device and limit registration to compliant devices or trusted locations. This breaks the step between stolen tokens and a PRT.
Turn on Continuous Access Evaluation. This is the answer to the token problem above. An access token is valid for an hour by default and the resource keeps honouring it until it expires, so revoking a session doesn’t stop the attacker straight away. CAE gives Entra a channel to tell Exchange Online, SharePoint Online and Teams to stop accepting that user’s tokens — and an administrator revoking refresh tokens is one of the five critical events that triggers it. Microsoft documents the response as near real time, with up to 15 minutes of propagation. Know the edges before you lean on it: it covers Exchange, SharePoint and Teams rather than everything, several client and resource combinations aren’t supported, it only understands IP-based named locations rather than country conditions, and it doesn’t apply to guest accounts at all.
Audit your Conditional Access properly. Policies that look complete often aren’t. Legacy exclusions, unscoped applications, break-glass accounts that drifted from what anyone documented. Misconfigured policies leave many organisations exposed with MFA enabled across the board. Go and check rather than assume!
Teach the specific behaviour. Dated phishing training won’t cover this. Add a device code scenario in your simulations if your platform supports one.
What to look for
If you can’t block the flow everywhere yet, these are the signals we alert on:
Device code sign-ins outside your baseline. Filter sign-in logs on the device code authentication protocol. Legitimate use is rare and predictable, so almost anything new deserves a look. Pay attention to unfamiliar IPs, unexpected countries and hosting provider ASNs.
Device registrations shortly after a sign-in. A registration a short time after an odd sign-in is a fairly high fidelity indicator.
Device names that break your convention. Registration tooling usually leaves a default name behind, and attackers rarely bother changing it. Baseline what your own devices are called and alert on the outliers.
Authentication broker activity. Broker applications sit on the path to device registration and PRT issuance, and attackers lean on them heavily.
Graph reconnaissance. User and group enumeration, directory role queries, audit log access. Watch the volume as much as the actions. Nobody reads the directory at that speed by hand. (Table name MicrosoftGraphActivityLogs)
Hunting queries
Use the switch below to flip every query between Microsoft Sentinel and Defender XDR. It is not cosmetic — the two platforms genuinely do not share column names, and several of these hunts have no Defender equivalent at all because the underlying table only exists in Sentinel. Mixing the two up is the most common reason a copied query quietly returns nothing.
1. Find the lure. Start here if you are working backwards from a report, or sweeping for a campaign. The mail tables are the same on both platforms — only the time column changes — because these are Defender for Office 365 tables that stream into Sentinel. Note this is deliberately not scoped to one mailbox: device-code lures are sprayed, and the recipient list is your blast radius.
// The lure itself: inbound mail carrying a link to the real Microsoft device-login
// page. Expect DeliveryAction = Delivered and a clean URL verdict — the link is
// genuine, so there is nothing for reputation filtering to catch.
EmailEvents
| where TimeGenerated > ago(30d)
| where EmailDirection == "Inbound"
| join kind=inner EmailUrlInfo on NetworkMessageId
| where Url contains "devicelogin" or Url contains "device-login"
| summarize Messages=count(), Recipients=make_set(RecipientEmailAddress, 50),
Subjects=make_set(Subject, 8), Urls=make_set(Url, 8),
FirstSeen=min(TimeGenerated), LastSeen=max(TimeGenerated)
by SenderFromAddress, SenderFromDomain, SenderIPv4
| order by Messages desc
And who actually clicked it. A click is not proof of redemption — confirm each one against the sign-in queries below.
// Who actually clicked. A click is not proof of redemption — confirm each one
// against the device-code sign-in queries below.
UrlClickEvents
| where TimeGenerated > ago(30d)
| where Url contains "devicelogin" or Url contains "device-login"
| project TimeGenerated, AccountUpn, Url, ActionType, IsClickedThrough, IPAddress, NetworkMessageId
| order by TimeGenerated desc
// The lure itself: inbound mail carrying a link to the real Microsoft device-login
// page. Expect DeliveryAction = Delivered and a clean URL verdict — the link is
// genuine, so there is nothing for reputation filtering to catch.
EmailEvents
| where Timestamp > ago(30d)
| where EmailDirection == "Inbound"
| join kind=inner EmailUrlInfo on NetworkMessageId
| where Url contains "devicelogin" or Url contains "device-login"
| summarize Messages=count(), Recipients=make_set(RecipientEmailAddress, 50),
Subjects=make_set(Subject, 8), Urls=make_set(Url, 8),
FirstSeen=min(Timestamp), LastSeen=max(Timestamp)
by SenderFromAddress, SenderFromDomain, SenderIPv4
| order by Messages desc
And who actually clicked it. A click is not proof of redemption — confirm each one against the sign-in queries below.
// Who actually clicked. A click is not proof of redemption — confirm each one
// against the device-code sign-in queries below.
UrlClickEvents
| where Timestamp > ago(30d)
| where Url contains "devicelogin" or Url contains "device-login"
| project Timestamp, AccountUpn, Url, ActionType, IsClickedThrough, IPAddress, NetworkMessageId
| order by Timestamp desc
2. Every device-code sign-in. Run this as a baseline. In most tenants the legitimate users are a short, boring list — Teams Rooms panels, some CLI tooling, a couple of service accounts. Once you know that list, anything outside it is worth a look.
// Every successful device-code sign-in. Legitimate use is rare and predictable,
// so baseline first and then alert on anything new.
SigninLogs
| where TimeGenerated > ago(30d)
| where AuthenticationProtocol =~ "deviceCode" or OriginalTransferMethod =~ "deviceCodeFlow"
| where ResultType == "0"
| summarize SignIns=count(), Apps=make_set(AppDisplayName,8), Resources=make_set(ResourceDisplayName,8),
FirstSeen=min(TimeGenerated), LastSeen=max(TimeGenerated)
by UserPrincipalName, IPAddress, Country=tostring(LocationDetails.countryOrRegion), ASN=AutonomousSystemNumber
| order by SignIns desc
Redemption is interactive, but the grants an attacker makes afterwards with the resulting refresh token are not. Run the same filter against AADNonInteractiveUserSignInLogs, which carries AuthenticationProtocol as well.
Not available in Defender advanced hunting. EntraIdSignInEvents has no AuthenticationProtocol or OriginalTransferMethod column, so you cannot filter on the device-code protocol at all. Baseline app and resource pairs instead and look for what does not belong.
// Defender has no device-code protocol column, so baseline app + resource pairs
// instead. A broker app or the Device Registration Service appearing for a user
// who has no reason to use them is the tell.
EntraIdSignInEvents
| where Timestamp > ago(30d)
| summarize SignIns=count(), Users=dcount(AccountUpn), IPs=make_set(IPAddress, 8),
Endpoints=make_set(EndpointCall, 8),
FirstSeen=min(Timestamp), LastSeen=max(Timestamp)
by Application, ApplicationId, ResourceDisplayName
| order by SignIns desc
3. A device-code token used against the Device Registration Service. The highest-fidelity single signal in the whole attack. It is the moment one approval becomes lasting access, and there is almost no legitimate reason for it.
// Highest-fidelity single signal: a device-code token being spent against the
// Device Registration Service. That is the step that turns one approval into
// lasting access, and it is almost never legitimate.
SigninLogs
| where TimeGenerated > ago(30d)
| where OriginalTransferMethod =~ "deviceCodeFlow"
| where ResourceDisplayName has "Device Registration"
| project TimeGenerated, UserPrincipalName, IPAddress, AppDisplayName, ResourceDisplayName,
Country=tostring(LocationDetails.countryOrRegion), ResultType
| order by TimeGenerated asc
Defender cannot see the protocol, so pivot on the destination instead. IsManaged and IsCompliant tell you whether the attacker’s device ended up satisfying your Conditional Access — if it did, a password reset changes nothing.
// Defender XDR has no AuthenticationProtocol column, so you cannot filter on the
// device-code protocol directly. Pivot on the destination instead: the broker app
// and the Device Registration Service are the tells.
EntraIdSignInEvents
| where Timestamp > ago(30d)
| where ResourceDisplayName has "Device Registration" or Application has "Authentication Broker"
| project Timestamp, AccountUpn, IPAddress, Country, City, Application, ApplicationId,
ResourceDisplayName, EndpointCall, DeviceName, DeviceTrustType, IsManaged, IsCompliant, ErrorCode
| order by Timestamp desc
4. Sign-in followed by a device registration. Registrations on their own are noisy and plenty are legitimate. The pairing is not. This joins the two and shows the gap between them.
// Device-code sign-in followed by a device registration by the same user within
// 30 minutes. Registration alone is noisy; the pairing is not.
let lookback = 30d;
let window = 30m;
let dc =
SigninLogs
| where TimeGenerated > ago(lookback)
| where AuthenticationProtocol =~ "deviceCode" or OriginalTransferMethod =~ "deviceCodeFlow"
| where ResultType == "0"
| project SignInTime=TimeGenerated, UserPrincipalName, SignInIP=IPAddress, AppDisplayName;
let reg =
AuditLogs
| where TimeGenerated > ago(lookback)
| where OperationName in ("Register device", "Add registered owner to device", "Add registered users to device")
| extend UserPrincipalName = tostring(InitiatedBy.user.userPrincipalName),
RegIP = tostring(InitiatedBy.user.ipAddress),
Device = tostring(TargetResources[0].displayName)
| project RegTime=TimeGenerated, UserPrincipalName, RegIP, Device, Result;
dc
| join kind=inner reg on UserPrincipalName
| where RegTime between (SignInTime .. (SignInTime + window))
| project SignInTime, RegTime, Delta = RegTime - SignInTime,
UserPrincipalName, SignInIP, RegIP, AppDisplayName, Device, Result
| order by SignInTime desc
Not available in Defender advanced hunting. Entra directory audit events — including device registration — are not in the Defender schema at all. There is no AuditLogs table to join against. Use Sentinel for this one, or the Entra admin center audit log.
5. Directory reconnaissance through Graph. Enumeration of users, groups and roles is the first thing an attacker does with a working token. Volume is the signal as much as the actions.
// Directory reconnaissance through Graph. Volume matters as much as the actions —
// nobody reads a directory at this speed by hand. Tune the threshold to your tenant.
MicrosoftGraphActivityLogs
| where TimeGenerated > ago(30d)
| where RequestUri has_any ("users", "groups", "directoryRoles", "auditLogs", "servicePrincipals", "applications")
| summarize Requests=count(), Endpoints=dcount(RequestUri), Sample=make_set(RequestUri, 10),
IPs=make_set(IPAddress, 8), Agents=make_set(UserAgent, 5),
FirstSeen=min(TimeGenerated), LastSeen=max(TimeGenerated)
by UserId, AppId
| where Requests > 200
| order by Requests desc
// Defender equivalent. Note the column names differ from Sentinel's table:
// IpAddress (lowercase p), ApplicationId, AccountObjectId.
GraphAPIAuditEvents
| where Timestamp > ago(30d)
| where RequestUri has_any ("users", "groups", "directoryRoles", "auditLogs", "servicePrincipals", "applications")
| summarize Requests=count(), Endpoints=dcount(RequestUri), Sample=make_set(RequestUri, 10),
IPs=make_set(IpAddress, 8),
FirstSeen=min(Timestamp), LastSeen=max(Timestamp)
by AccountObjectId, ApplicationId, TargetWorkload
| where Requests > 200
| order by Requests desc
6. Device registrations that break your naming convention. Tooling that registers devices tends to leave a default name behind. Rather than chase one tool’s default — which changes between versions and which any operator can override — invert it and alert on anything that does not match the convention your own estate uses.
// Device registrations whose name doesn't match your naming standard. More durable
// than chasing one tool's default, which changes and which any operator can override.
AuditLogs
| where TimeGenerated > ago(30d)
| where OperationName in ("Register device", "Add registered owner to device")
| extend Device = tostring(TargetResources[0].displayName),
Actor = tostring(InitiatedBy.user.userPrincipalName),
ActorIP = tostring(InitiatedBy.user.ipAddress)
| where Device !startswith "CORP-" // <-- replace with your convention
| project TimeGenerated, Actor, ActorIP, Device, Result
| order by TimeGenerated desc
Not available in Defender advanced hunting. Same reason as above: device registration is an Entra audit event, and Defender advanced hunting has no AuditLogs table. Sentinel or the Entra portal.
If you would rather not write these by hand, the BEC Checker on this site generates them and a wider account-compromise set, pre-filled with the account and time window, with the same platform switch. Everything runs in your browser.
One warning on indicators. You’ll see login.microsoftonline.com and microsoft.com/devicelogin listed as IOCs in various write-ups. They’re legitimate Microsoft URLs and alerting on them alone will bury your queue. What you’re actually looking for is an unsolicited message containing one of those URLs alongside a code the recipient never asked for.
If you’re responding to one
Revoking the session isn’t enough. Refresh tokens and PRTs outlive it.
Reset the password.
Revoke refresh tokens explicitly. With CAE enabled this reaches Exchange, SharePoint and Teams within minutes; without it, tokens already issued keep working until they expire.
List devices registered to the account and remove anything that appeared around the incident window.
Check mailbox rules, forwarding rules and delegated mailbox permissions.
Check for OAuth application consent grants added during the window.
Review Graph activity to work out what the attacker already enumerated.
Check Exchange, SharePoint and OneDrive for data access and exfiltration.
Assume the directory was enumerated before you noticed. Scope the investigation to what the attacker could have learned, not just what you can see them touching.
The takeaway
Device code phishing works because it exploits trust rather than technology. The user authenticates. The URL is real. The login page is Microsoft’s. The only malicious part is the person who started the workflow.
The easy solution? There isn’t a single one, but there is a short list. Block the flow where it isn’t needed, restrict who can register devices, turn on Continuous Access Evaluation so that revoking a session actually bites, and monitor what’s left.