CSIPE

Published

- 33 min read

Device Code Phishing: How Attackers Walk Past MFA Without Breaking It


The Digital Fortress: Your Everyday Guide to a Safer Digital Life

Stay Safe Online Without Making It Your Second Job

The Digital Fortress (Second Edition)

A warm, plain-English guide for people with real lives and finite patience. Learn the handful of habits that genuinely protect your money, accounts, and family, and get honest permission to ignore the rest.

Buy the book now
The Anonymity Playbook: Digital Survival for Whistleblowers, Journalists, Activists, and Everyone Else

For People Who Cannot Afford to Get Privacy Wrong

The Anonymity Playbook (Second Edition)

A practitioner’s field manual for journalists protecting sources, whistleblowers, and activists. It explains how the surveillance actually works, what each technique costs you, and exactly where it fails.

Buy the book now
Secure Software Development: Practical patterns for building secure software

Write, Ship, and Maintain Code Without Shipping Vulnerabilities

Secure Software Development

A hands-on security guide for developers and IT professionals who ship real software. Build, deploy, and maintain secure systems without slowing down or drowning in theory.

Buy the book now
The Secure Harness: Shipping Production Code with AI Coding Agents

Use AI Coding Agents Without Losing Control of Your Codebase

The Secure Harness

A calm, practical guide to letting agents do useful work inside boundaries you set, enforce, and audit. Ships with 15 copy-pasteable artifacts: hook scripts, permission configs, release gates, and MCP templates.

Buy the book now
The AI Native Engineer: Build, Evaluate, and Ship AI Systems That Work in Production

Stop Shipping Demos. Start Shipping Systems.

The AI Native Engineer

Sixteen hands-on chapters, one real product. Grow it from a single model call into a retrieved, tool-using, observable, production-grade system, with evaluation treated as a habit from the first feature.

Buy the book now

An attack with no fake login page

Phishing defences are built around a shared assumption: somewhere in the attack there is a counterfeit. A lookalike domain, a cloned login form, a proxy sitting between the user and the real service. Train users to check the URL, deploy a tool that detects impersonating domains, and you have covered the category.

Device code phishing removes the counterfeit entirely. The victim visits microsoft.com, on the genuine site, over a valid certificate, and authenticates with their real credentials and their real second factor. Everything they were trained to check is correct. At the end of it the attacker holds valid access and refresh tokens for their account.

Reporting in July 2026 described toolkits including Kali365 and EvilTokens packaging this into ready-made campaigns, with AI-generated lures and automated token harvesting, affecting hundreds of Microsoft 365 tenants.

What the device code flow is for

The flow exists for a real problem. Some devices cannot present a usable browser: a smart TV, a CLI tool on a headless server, a conference room display. Typing a password with a remote control is miserable, so OAuth 2.0 defines a device authorization grant, specified in RFC 8628.

The device asks the identity provider to start a flow. The provider returns a short user code and a URL. The device shows both to the user, who goes to that URL on their phone or laptop, signs in normally, and enters the code. Meanwhile the device polls the token endpoint. Once the user completes authentication, the poll returns tokens.

The design has a property that makes it useful and also makes it attackable: the thing being authorised and the thing doing the authorising are on different devices, connected only by a short code the user types. Nothing in the flow proves the user is looking at the device they think they are authorising.

Turning it into an attack

The attacker starts the flow themselves, against the tenant they are targeting:

   POST /organizations/oauth2/v2.0/devicecode HTTP/1.1
Host: login.microsoftonline.com
Content-Type: application/x-www-form-urlencoded

client_id=<a well-known first-party client id>
&scope=https://graph.microsoft.com/.default offline_access

The response contains a user_code, a verification_uri pointing at Microsoft, and a device_code the attacker keeps. The user code is typically valid for about fifteen minutes.

The attacker then sends a message that gets the victim to enter that code. The pretext writes itself, because the instructions are genuinely legitimate: “To access the shared document, go to microsoft.com/devicelogin and enter code F7X9K2LM.” Every element the victim can inspect is authentic. Modern campaigns generate these lures at scale and personalise them with details scraped from the target organisation.

The victim authenticates. Their password is correct, their MFA prompt appears on their real device and they approve it, because they are performing a genuine authentication. Microsoft issues tokens to the pending device code. The attacker’s polling loop collects them:

   POST /organizations/oauth2/v2.0/token HTTP/1.1
Host: login.microsoftonline.com

grant_type=urn:ietf:params:oauth:grant-type:device_code
&client_id=<same client id>
&device_code=<the code from step one>

The offline_access scope in the initial request is what makes this durable. The response includes a refresh token, typically valid for ninety days and renewable, so the attacker retains access long after the fifteen-minute window closes and long after the victim changes their password.

The exchange in full

The two requests shown earlier are the visible ends of a longer conversation, and the part between them is where the timing of the attack is set. The attacker’s opening request initiates the grant and gets back a small JSON document that carries every value the rest of the flow depends on.

   POST /organizations/oauth2/v2.0/devicecode HTTP/1.1
Host: login.microsoftonline.com
Content-Type: application/x-www-form-urlencoded

client_id=<first-party client id>&scope=https://graph.microsoft.com/.default offline_access

The provider answers with the code the victim will type, the code the attacker will keep, and the clock they are both racing.

   {
	"user_code": "F7X9K2LM",
	"device_code": "GMMhmHCX...redacted...9E8",
	"verification_uri": "https://microsoft.com/devicelogin",
	"expires_in": 900,
	"interval": 5,
	"message": "To sign in, use a web browser to open https://microsoft.com/devicelogin and enter the code F7X9K2LM to authenticate."
}

Three fields decide the tempo. expires_in is the deadline, 900 seconds in the default Microsoft configuration, which is why the lure has to land inside a quarter of an hour. interval is the minimum gap between polls, and asking for tokens faster than that earns a throttling response. The message field is the detail that makes this attack cheap to run at scale, because Microsoft writes clean, correct sign-in instructions and the operator forwards them without editing a word.

With the code in the victim’s inbox, the attacker begins polling the token endpoint. Every poll before the victim finishes returns a status rather than a credential, and the status values are worth knowing because they show up in traffic and in tooling.

   { "error": "authorization_pending" }

The endpoint speaks a small vocabulary. authorization_pending means keep waiting, the user has not finished. slow_down means the poll interval was too aggressive, so back off. expired_token means the fifteen minutes elapsed and the flow is dead. access_denied means the user, or a policy, refused. The moment the victim completes sign-in and enters the code, the next poll returns the payload the whole exercise was for: an access token, a refresh token because offline_access was requested, an ID token, and the scopes that were granted.

Laid out as a sequence, the legitimate flow and the attack are the same protocol with one participant swapped. In a genuine device code sign-in the person holding the phone and the person controlling the device are the same. In the attack they are two different people, joined only by a code that travelled through email.

Victimlogin.microsoftonline.comAttackerVictimlogin.microsoftonline.comAttackerloop[every 5s until token or expiry]POST /devicecode (client_id, scope + offline_access)user_code, device_code, verification_uri, expires_in=900"Open microsoft.com/devicelogin, enter F7X9K2LM"POST /token (grant_type=device_code, device_code)authorization_pendingSign in (password + MFA), enter F7X9K2LM"You are signed in"POST /token (device_code)access_token + refresh_token (offline_access)Use tokens against Graph from attacker infrastructure

What a real campaign looks like

None of this stayed theoretical. In February 2025 Microsoft documented a device code phishing campaign it tracks as Storm-2372, a group it assesses with moderate confidence as acting in Russian state interests, and the operation had already been running since August 2024. The write-up is useful because it shows how the plain flow gets dressed up into something a careful person will fall for.

The approach started with rapport rather than a link. Operators reached targets through consumer messaging apps such as Signal and WhatsApp, posing as a well-known person relevant to the target’s work, and only after a few exchanges did they send what looked like an invitation to an online meeting. The invite carried the device code and the genuine Microsoft instructions. The victim, expecting to join a Teams call, entered the code and authenticated for real.

Then the campaign got sharper. Early on the operators used a generic client, but they moved to the client ID belonging to Microsoft Authentication Broker, and that change matters. A refresh token issued to the broker can be exchanged for a token against the device registration service, which lets the attacker enrol a device they control into the victim’s tenant. Once that device exists, they can request a Primary Refresh Token, and a PRT sits close to the crown jewels: it behaves like the credential of a fully registered, trusted device and mints tokens for a wide range of resources.

After the tokens were in hand, the operators went straight to the mailbox through Microsoft Graph and searched it for words that surface secrets and targets: username, password, admin, teamviewer, anydesk, credentials, secret, ministry, and gov. Matching mail was pulled out over the same API. Compromised accounts were then used to phish other people inside the same organisation, which is how one entry point turned into several.

The sectors hit were the ones you would expect a state-aligned group to want: government, NGOs, IT and technology services, defence, telecommunications, health, higher education, and energy, spread across Europe, North America, Africa, and the Middle East. The same playbook works against a mid-size company with no geopolitical relevance, because the flow does not care who you are.

What began as a bespoke, hands-on operation did not stay that way. The July 2026 reporting mentioned at the top of this page, with toolkits such as Kali365 and EvilTokens, is the predictable next step, because once a technique proves itself against hardened targets someone packages it for everyone else. The rapport-building that Storm-2372 did by hand becomes an AI-written lure produced in seconds, the manual polling loop becomes a hosted service, and the mailbox triage becomes an automated harvest keyed on the same secret-hunting words. The barrier to running this attack has dropped from a funded intelligence team to a monthly subscription, so the volume climbs even while the mechanics stay identical. A control that stops the mechanics stops the state actor and the commodity kit alike, which is the encouraging half of the story: the fix does not change when the attacker does.

The lesson to carry forward is that device code phishing is a funded, iterated technique with real tenants behind it, not a curiosity in a researcher’s slide deck.

Why the usual controls miss it

Multi-factor authentication is not bypassed here, which is the detail that makes the attack effective against organisations who believed MFA closed this category. MFA is satisfied correctly by the legitimate user. The flaw is that the user is authorising a session they did not initiate and cannot see.

Phishing-resistant authenticators help less than expected for the same reason. A FIDO2 security key cryptographically binds authentication to the origin, which defeats credential-relay proxies completely. In the device code flow the origin is login.microsoftonline.com, which is the correct origin, so the key signs happily. The channel that gets abused is the out-of-band code, and the key never sees it.

Conditional access policies frequently evaluate the properties of the authenticating session, which is the victim’s real browser on their real corporate laptop from their normal location. Everything looks compliant, because it is. The tokens are then used from the attacker’s infrastructure, which is a separate event that many policy configurations do not re-evaluate.

How device code phishing compares to its cousins

It helps to place this attack next to the ones it is often confused with, because the defences that work are different for each and a control that stops one can leave another wide open. Four phishing families dominate credential and session theft against Microsoft 365, and they differ mainly in what the victim is looking at and what the attacker walks away with.

TechniqueWhat the victim seesWhat the attacker ends up withWhat actually stops it
Credential harvestingA fake login page on a lookalike domainA username and password, no second factorAny MFA; URL awareness
AiTM proxy (Evilginx and similar)A real login page relayed through a proxyThe live session cookie, MFA already satisfiedPhishing-resistant MFA; token protection
Device code phishingThe genuine Microsoft page, correct URLAccess and refresh tokens for the accountBlocking the flow in Conditional Access; training on initiation
Consent phishingA real Microsoft consent promptA standing OAuth grant to a malicious appAdmin consent workflow; app governance; restricting user consent

The table sorts the confusion out quickly. Credential harvesting dies the moment any second factor is in place, which is why it is now the least effective of the four against a defended tenant. AiTM proxying was the answer attackers found to MFA: it sits in the middle, lets the real login and the real second factor happen, and steals the resulting session cookie, so the victim’s address bar shows a lookalike domain that a trained eye can catch. Microsoft reported a 146 percent rise in AiTM attacks across 2024, with the trend accelerating through 2025 as phishing-as-a-service kits made the technique rentable by anyone. Phishing-resistant authenticators break AiTM cleanly, because the cryptographic assertion is bound to the true origin and a proxy is a different origin.

The direction of travel matters for where you spend. Identity-based intrusion has overtaken malware as the primary way attackers get in, and reporting through 2025 tracked a sharp climb in compromised-credential volume, roughly half again as many in the second half of the year as in the same period a year earlier. Attackers have shifted from stealing passwords to stealing sessions and tokens, precisely because tokens sail past the second factor that passwords no longer beat. Device code phishing and consent phishing sit at the token-first end of that shift, which is why controls aimed only at passwords keep coming up short.

Device code phishing steps around that defence by never introducing a false origin at all. The victim is on the real page, the URL is correct, and the phishing-resistant key signs for the legitimate site because the site genuinely is legitimate. Consent phishing shares that property: the consent screen is real, the app is malicious, and the user grants it access with a click. The through-line is that the two newer techniques both move the deception out of the login page, where a decade of training and tooling has been pointed, and into a decision the user makes while standing on trusted ground.

What MFA actually defends against

Since MFA is the control most people assume closes this hole, it pays to be precise about what each kind of second factor does and does not cover. The factors sit on a spectrum from weak to phishing-resistant, and that spectrum maps almost exactly onto which attacks they stop.

FactorPassword theftPush fatigueAiTM proxyDevice code phish
SMS or voice OTPStopsVulnerableVulnerableVulnerable
TOTP authenticator appStopsVulnerableVulnerableVulnerable
Push approval (plain)StopsVulnerableVulnerableVulnerable
Number-matching pushStopsResistsVulnerableVulnerable
FIDO2 key or passkeyStopsResistsStopsVulnerable
Certificate-based authStopsResistsStopsVulnerable
Windows Hello for BusinessStopsResistsStopsVulnerable

Read down the last column and the point lands hard: nothing in the MFA catalogue stops device code phishing on its own. The phishing-resistant options at the bottom, the ones organisations spend real money and change-management effort rolling out, defeat AiTM proxying because they refuse to sign for the wrong origin. In device code phishing there is no wrong origin for them to refuse, so the strongest authenticator in the portfolio signs the assertion and the flow completes. This trips up even mature security programmes.

A tenant can be fully passwordless, every user on a FIDO2 key, every legacy protocol disabled, and still hand an attacker working tokens through the device code flow, because the flow was designed to let a trusted authentication on one device authorise a session on another. The number-matching row deserves a note too: matching a number defeats blind push-approval fatigue, since the user has to read a value off a screen they initiated, but it does nothing here because the user did initiate a real sign-in, on the real page, matching a real number.

The practical conclusion is that MFA strength and device code phishing sit on different axes. Investing in phishing-resistant credentials is still correct and still the right answer to AiTM, and it should proceed regardless. Just do not file device code phishing under “solved by better MFA”, because the two problems need different controls and the fix for this one is turning the flow off for the people who never use it.

Blocking it

The primary control is straightforward: disable the device code flow for users who do not need it. In Entra ID this is a conditional access policy targeting the device code grant.

Most organisations need it for approximately nobody. The legitimate use cases are specific and enumerable, and they usually belong to a small group of engineers using CLI tools against headless systems. Scope an exclusion group for those people, block the flow for everyone else, and the attack surface is gone rather than reduced.

   Conditional Access policy
  Assignments
    Users:              All users
    Excluded:           grp-device-code-approved   (documented, reviewed)
    Target resources:   All cloud apps
    Conditions:
      Authentication flows: Device code flow  -> selected
  Access controls
    Grant: Block access

Where the flow must remain available, narrow the blast radius. Restrict device registration so that a token obtained this way cannot be used to enrol a new device into the tenant, which is a common follow-on step. Shorten refresh token lifetimes for sessions originating from this grant, so the durability that makes the attack valuable is reduced.

Rolling the block out without breaking anyone

The policy shown above is a few lines in the portal, and flipping it straight to enforce is how you generate a wave of broken build agents and irritated engineers by nine the next morning. A calmer rollout has three moves: see who actually uses the flow, enforce in report-only first, then narrow the exception group to the smallest set that survives scrutiny.

Start by measuring, because most tenants have no idea whether anyone relies on the device code grant. Entra sign-in logs record the authentication protocol, so a single query over the last month tells you who to talk to before you break them.

   SigninLogs
| where TimeGenerated > ago(30d)
| where AuthenticationProtocol == "deviceCode"
| where ResultType == 0
| summarize SignIns = count(),
            Apps = make_set(AppDisplayName, 10),
            Locations = make_set(Location, 10),
            LastSeen = max(TimeGenerated)
    by UserPrincipalName
| sort by SignIns desc

The output is usually short and revealing. Expect a handful of engineers running az login --use-device-code against headless boxes, a service-style account behind a CI job, and often a few sign-ins from users who were phished and never noticed. Everyone on that list needs a conversation. The engineers move into a documented exception group, the CI job moves to a client-credentials or managed-identity pattern that needs no human at all, and the mystery entries get investigated as possible incidents.

The CI migration is worth doing properly rather than papering over with an exception. A pipeline that authenticated through the device code flow was almost always doing it for convenience, and the correct replacement depends on where it runs. A workload inside Azure should use a managed identity, a workload outside it should use a service principal with a certificate or a federated credential, and a genuinely interactive tool on a developer’s machine can use the authorization code flow with PKCE instead. Each of those removes a human from the loop, which is the exact property the attack depends on.

With the users known, deploy the policy in report-only mode. Report-only evaluates the policy on every sign-in and records what it would have done without blocking anything, so you collect a week of evidence that the block would only ever have caught the people you expect. Watch those results, confirm no surprise applications appear, and only then switch the policy to enforce.

One flow is easy to forget. Microsoft groups device code flow with a sibling called authentication transfer, the QR-code handoff that moves a signed-in state from a desktop to a phone, and it carries a related risk. Cover both in the same authentication-flows condition, or write a second policy for the transfer flow, so you are not closing one door while the other stands open.

The decision the policy makes on each sign-in is small enough to draw, and drawing it helps when you explain the design to an auditor or a sceptical platform team.

No

Yes

No

Yes

Sign-in uses device code flow

User in grp-device-code-approved?

Block access

Device managed and compliant?

Allow, apply short session lifetime

Recorded in SigninLogs, alert if repeated

Anti-patterns that quietly reinstate the risk

Every control on this page can be implemented in a way that looks finished on a compliance dashboard and does almost nothing in practice. The failure modes repeat across tenants, so they are worth naming directly, with the fix beside each one.

  • An exclusion group so broad it swallows the policy. An exception named for a team of three engineers is fine. An exclusion set to “all guest users” or an entire department reopens the flow for exactly the population an attacker is happy to reach. Keep the exclusion small, named, and documented, and put a recurring review against it so the membership does not rot.
  • Living in report-only forever. Report-only is a testing state, and a policy that has sat in it for six months has never blocked anything. Set an enforcement date when you deploy, and treat any report-only policy older than a month as an open finding.
  • Blocking device code but leaving authentication transfer open. These two flows are cousins with the same weakness, and stopping one while ignoring the other is a common half-measure. Cover both.
  • Trusting FIDO2 to cover this. Phishing-resistant credentials are the right investment and they defeat AiTM, yet they sign happily inside the device code flow because the origin is genuine. A passwordless tenant still needs the flow blocked.
  • Resetting the password and calling it done. A password reset does not touch a stolen refresh token, which keeps minting access tokens for its full lifetime. Revoke sessions first, then reset, then hunt for the persistence the attacker planted.
  • Forgetting the device registration path. The Storm-2372 escalation ran through registering an attacker device to reach a PRT. If device registration is open to any user with a token, blocking the flow for most people still leaves that route available to the accounts you excluded. Restrict who can join devices, and require compliant or hybrid-joined devices for sensitive resources.
  • Break-glass accounts excluded from everything and watched by nobody. Emergency access accounts are correctly excluded from Conditional Access so a policy mistake cannot lock you out of your own tenant, but an unwatched excluded account is a permanent hole. Alert on every sign-in they perform.

The pattern behind the pattern is that each shortcut trades a real reduction in risk for the appearance of one. A block with a department-wide exclusion, a report-only policy that never enforces, or a password reset that leaves the refresh token alive all pass a glance and fail against an attacker.

Detecting the ones that got through

Two signals are reliable and worth building even after blocking the flow, because exclusions accumulate and policies get edited.

Sign-in logs distinguish the authentication protocol. A successful sign-in where the protocol is the device code grant, from a user outside your approved group, is either a policy gap or an attack, and both need attention. In Entra ID this appears in the sign-in logs with an authentication protocol of deviceCode.

The second signal is the geographic split that this attack creates structurally. The interactive authentication comes from the victim’s normal location and device. The subsequent token usage comes from wherever the attacker is. A sign-in from Frankfurt followed within minutes by Graph API calls bearing the same session from a hosting provider in another country is the shape of a successful device code phish, and it is visible in unified audit logs.

Watch for inbox rule creation immediately after any suspicious token issuance. Creating a rule that files security notifications into a rarely-read folder is close to universal in these campaigns, because it suppresses the alerts that would otherwise reach the victim.

Detection engineering that survives contact

The section above named the signals; turning them into alerts that fire on real attacks and stay quiet the rest of the time is its own small engineering problem. The queries below are written for Microsoft Sentinel and Advanced Hunting, and each pairs with a note on how to tune it so it does not drown you.

The first query is the base case, a successful device code sign-in from someone outside the approved exception group. Keep the approved list in a watchlist or a variable so the query stays readable and the exceptions stay auditable.

   let approved = dynamic(["[email protected]", "[email protected]"]);
SigninLogs
| where TimeGenerated > ago(1h)
| where AuthenticationProtocol == "deviceCode"
| where ResultType == 0
| where UserPrincipalName !in~ (approved)
| project TimeGenerated, UserPrincipalName, AppDisplayName,
          IPAddress, Location, DeviceDetail

The second reproduces the Storm-2372 pattern by joining the click of a device-login link to a successful sign-in by the same user soon after. It needs Defender for Office 365 data for UrlClickEvents, and the time window is the knob to tune.

   let window = 15m;
let clicks = UrlClickEvents
| where Url has_any ("microsoft.com/devicelogin",
                     "login.microsoftonline.com/common/oauth2/deviceauth")
| project ClickTime = Timestamp, AccountUpn;
let signins = AADSignInEventsBeta
| where ErrorCode == 0
| project SignInTime = Timestamp, AccountUpn, IPAddress, Country;
clicks
| join kind=inner signins on AccountUpn
| where SignInTime between (ClickTime .. ClickTime + window)
| project AccountUpn, ClickTime, SignInTime, IPAddress, Country

The third catches the escalation rather than the entry. When an attacker registers a device to reach a PRT, the Device Registration Service leaves a trail, and a new device appearing within minutes of a suspicious sign-in earns an alert on its own.

   CloudAppEvents
| where Timestamp > ago(1h)
| where ActionType in ("Add registered owner to device.", "Add device")
| project Timestamp, Account = RawEventData.UserId,
          Device = RawEventData.ModifiedProperties, IPAddress

Two behavioural signals round the set out, and both are cheap to run. The geographic split is structural to this attack, since the interactive sign-in comes from the victim’s normal location and the token is used from the attacker’s infrastructure, so a successful device code sign-in followed by Graph calls from a different country or a hosting-provider ASN within the hour is a strong lead. The other is inbox-rule creation right after a suspicious token, which shows up as a New-InboxRule or Set-InboxRule operation in OfficeActivity or CloudAppEvents, and a rule that files security mail into a rarely-read folder is close to a signature move for these campaigns.

Before any of these go live, baseline them for a week in a non-alerting mode. The device code query in particular will surface the legitimate exception users on every run, which is why the approved list belongs in the query rather than in someone’s memory, since without it the alert cries wolf until an analyst mutes it, and a muted alert is worse than none. The click-to-sign-in join is the noisiest of the set, because a user can click a device-login link for entirely innocent reasons, so treat a single hit as a lead to enrich rather than an incident to declare, and raise its confidence only when a second signal, a new country or a fresh device registration, lines up behind it.

Tune all of these for time first and identity second. The windows should be tight because the attacker moves in minutes, and the identity filters should reference the same approved list your Conditional Access policy uses, so detection and prevention never drift apart.

What the attacker does next

Understanding the post-authentication sequence matters, because that is where most of the damage occurs and where detection still has a chance.

The first action is almost always reconnaissance through Microsoft Graph. A token with .default scope against Graph can enumerate the directory: users, groups, roles, and often devices. This tells the attacker who the administrators are and where the valuable mailboxes sit, and it looks like ordinary application traffic.

Mail access follows. Mailboxes are the highest-value target in a Microsoft 365 tenant because they contain everything: password reset links, invoices with bank details, contract negotiations, and the internal context needed to make a follow-on fraud convincing. Bulk mailbox download through Graph is fast and generates a distinctive pattern of API calls.

Persistence comes next and takes several forms. Creating an inbox rule that files security alerts away is the most common. Registering an additional authentication method on the compromised account gives durable access independent of the stolen token. Consenting to a malicious OAuth application on behalf of the user creates access that survives password resets, token revocation, and in many configurations goes unnoticed because application consents are rarely reviewed.

Lateral movement then uses the mailbox as a launch point. An internal email from a real colleague’s real account, in an existing thread, is the most effective phishing available, and it is how these compromises spread from one account to a finance team.

The window between token issuance and mailbox download is typically minutes, which sets the bar for useful detection. Alerting that runs hourly is documentation rather than defence.

The tokens you are actually defending

Everything the attacker does after the code is entered runs on tokens, so revocation and detection only make sense once you know what the flow hands out and how long each piece lives. A single successful device code phish produces a small family of credentials, and they have very different lifetimes and very different blast radii.

The access token is the short-lived one. It is a bearer token, meaning whoever holds it is treated as the user, and it stays valid for roughly an hour by default. A password change does not affect it, and neither does much else until it simply expires. On its own it buys a one-hour window.

The refresh token is the durable problem. It exists because offline_access was in the original scope request, it lasts around ninety days in default configurations, and it renews itself each time it is used, so an attacker who keeps exercising it can hold access far longer than the fifteen-minute code window that started everything. This is the credential that makes the attack worth running, and the one a password reset leaves untouched.

Above both sits the Primary Refresh Token, the prize when an attacker manages the device-registration escalation. A PRT behaves like the credential of a fully enrolled, trusted device, and it can issue tokens across a broad set of resources with the single-sign-on properties of a real corporate machine. Reaching a PRT turns an account compromise into something much closer to a device compromise.

There is a fourth item that outlives all of the above, an OAuth consent grant. If the attacker gets the user, or tricks an admin, into consenting to an application, that grant is a standing permission that keeps working after passwords change, after sessions are revoked, and after the original tokens die. It is the quietest form of persistence in Microsoft 365 because application consents are rarely reviewed, and it is why consent audit belongs in every device code incident.

Continuous access evaluation improves this picture where it applies. CAE lets certain resources, notably Exchange Online and SharePoint, react to critical events such as a session revocation in near real time rather than waiting for the access token to expire, which shrinks the one-hour window that bearer tokens otherwise guarantee. Its coverage stops short of every resource and every token type, so it reduces the exposure without removing the need to revoke deliberately.

Token protection, a newer Conditional Access capability, attacks the theft problem from a different angle by cryptographically binding a refresh token to the device that obtained it, so a token lifted and replayed from other hardware stops working. Its coverage is still growing and it does not yet reach every client or resource, but for the sessions it does protect it removes the portability that makes a stolen refresh token valuable. Enable it where supported, as a second layer behind the flow block rather than a replacement for it.

Knowing which of these four credentials you are dealing with is what turns a panicked password reset into a containment that actually holds, which is the subject of the next section.

Revoking access properly

When you find one, the instinct is to reset the password. On its own that accomplishes very little, and knowing why saves an incident from running twice.

An access token is a bearer credential valid until it expires, typically an hour, and it is not invalidated by a password change. The refresh token is the larger problem: it survives password resets in default configurations and can be exchanged for new access tokens for its full lifetime.

The sequence that actually terminates access starts with revoking the user’s sign-in sessions, which invalidates refresh tokens. Then reset the password. Then review and remove any authentication methods the attacker registered, because a rogue authenticator app re-establishes access immediately after everything else is cleaned up. Then audit OAuth application consents granted by that user, which is the step most often skipped and the one that leaves a persistent foothold.

   # Invalidate refresh tokens for the account, then verify what else changed.
Revoke-MgUserSignInSession -UserId "[email protected]"

# Authentication methods the attacker may have added.
Get-MgUserAuthenticationMethod -UserId "[email protected]" |
  Select-Object Id, AdditionalProperties

# Inbox rules, where alert-suppression rules hide.
Get-InboxRule -Mailbox "[email protected]" |
  Select-Object Name, Enabled, MoveToFolder, DeleteMessage, ForwardTo

Finally, check for tenant-wide effects. If the compromised account held any administrative role, the scope of the incident is the tenant rather than the mailbox, and the review needs to cover directory changes, new applications, and role assignments made during the window.

A containment runbook for the first thirty minutes

The revocation order in the previous section is correct, and during a live incident at an awkward hour you want it as a checklist you can run without reasoning it out each time. The runbook below assumes an alert has fired for a device code sign-in from an unapproved user and you have confirmed it is not a sanctioned exception.

Draw the sequence once so the whole team follows the same path, because the expensive mistakes in these incidents are order-of-operations mistakes, such as resetting the password before revoking sessions, or closing the ticket before checking for consent grants.

Yes

No

Alert: device code sign-in, unapproved user

Revoke sign-in sessions

Disable account or force reauth

List auth methods, remove attacker-added ones

Review inbox rules, remove alert-suppression

Audit OAuth app consents granted by user

Account held an admin role?

Escalate to tenant-scope review

Reset password, restore legit mailbox rules

Document window, confirm no new tokens issued

Each box maps to a command, and keeping them in one script means the responder runs them in order instead of improvising. The first three steps cut off live access, and the rest hunt for the persistence the attacker plants before you arrive.

   # 1. Kill live access by revoking refresh tokens for the account.
Revoke-MgUserSignInSession -UserId "[email protected]"

# 2. Hold the account while you investigate.
Update-MgUser -UserId "[email protected]" -AccountEnabled:$false

# 3. Authentication methods the attacker may have registered.
Get-MgUserAuthenticationMethod -UserId "[email protected]" |
  Select-Object Id, AdditionalProperties

# 4. Inbox rules, where alert-suppression hides.
Get-InboxRule -Mailbox "[email protected]" |
  Select-Object Name, Enabled, MoveToFolder, DeleteMessage, ForwardTo

# 5. OAuth grants the user consented to, the persistence step most often missed.
Get-MgUserOauth2PermissionGrant -UserId "[email protected]" |
  Select-Object ClientId, ResourceId, Scope

The branch in the flowchart is the one decision that changes the size of the incident. If the compromised account held any directory role, the scope is the tenant rather than the mailbox, and the review has to cover new applications, role assignments, and directory changes made inside the compromise window. For a standard user the incident usually ends at the mailbox, though only after the consent audit comes back clean.

Two non-technical steps run in parallel with the commands. Preserve evidence before you disable anything, which means exporting the sign-in and audit logs for the account and the window, because default retention can age them out before the investigation finishes. Then handle communication with care, since a compromised mailbox may be under the attacker’s eye, so coordinate through an out-of-band channel rather than emailing the victim about the incident on the very account you are trying to contain. For a suspected admin compromise or a regulated data set, bring legal and your incident-response lead in early rather than after the technical work wraps up.

Close the runbook by confirming the negative. After revocation, watch the sign-in logs for that account for new token issuance over the next hour. A fresh token appearing means a persistence mechanism was missed, and the runbook restarts from the consent and device-registration checks rather than from the password.

Proving the policy works before you trust it

A Conditional Access policy that has never been tested is a belief rather than a control, and this one is easy to verify against your own tenant in a few minutes. The goal is to confirm two things: the block fires for an ordinary user, and the exception group still works for the people who need the flow. Do this only against a tenant you own or have written authorisation to test.

Set up a canary. Create a test account that belongs to no exception group, and a second test account inside grp-device-code-approved, so you can watch the policy make both decisions. Then start a real device code flow from a command line, which is the cleanest way to trigger the grant without any attacker tooling.

   # Start a genuine device code flow against your own tenant.
az login --use-device-code --tenant <your-tenant-id>

The CLI prints a code and the microsoft.com/devicelogin URL. Complete the sign-in in a browser as the unapproved canary account. With the policy enforced, the sign-in should stop at the Conditional Access block rather than returning tokens, and the failure surfaces to the user as a blocked-by-policy message and in the logs as error 53003.

Confirm the block in the sign-in logs, because the portal message alone is easy to misread. A short query shows the decision the policy recorded.

   SigninLogs
| where TimeGenerated > ago(15m)
| where AuthenticationProtocol == "deviceCode"
| where UserPrincipalName == "[email protected]"
| project TimeGenerated, ResultType, ResultDescription,
          ConditionalAccessStatus, AppDisplayName

A ResultType of 53003 with a ConditionalAccessStatus of failure is the outcome you want for the unapproved account. Repeat the same login as the approved canary and confirm the opposite: the flow completes and ConditionalAccessStatus reads success, which proves the exception group is scoped correctly and you have not accidentally blocked the engineers who raised the requirement.

Two more checks finish the validation. While the policy is still in report-only, run the same test and read the report-only columns in the sign-in detail, which show what the policy would have done without affecting the result, and that is how you build confidence before enforcing. For teams that run purple-team exercises, framework tooling such as ROADtools or AADInternals can drive the device code grant in a more attacker-realistic way, though the plain az login test is enough to prove the control works and carries none of the risk of running offensive tooling against production identities.

Retest after any change to the policy or its exclusions. Conditional Access gets edited often, exclusion groups accumulate members, and a control that worked in January can quietly stop covering half your users by June if nobody checks.

Telling users something useful

Security awareness training that says “check the URL” actively fails against this attack, since the URL is correct and a user who verifies it becomes more confident, not less.

The rule that transfers is about initiation rather than appearance. If you did not start a sign-in, do not complete one. A code that arrives by email, chat, or phone call, which you are asked to enter somewhere, is a request to authorise something you did not begin. That framing also covers push-notification fatigue attacks and one-time-code relay, so it is worth more than a device-code-specific warning.

The broader point for anyone designing authentication is that this flow was correctly implemented, standards-compliant, and abusable, because its security depends on a human correctly understanding what they are approving. Any time a protocol makes a person the integrity check between two channels, the attack is a matter of writing a convincing sentence.