OAuth application intelligence
Search any OAuth Application ID across identity platforms. Triage in one click: legitimate references for allowlisting, OAuth apps abused by threat actors, and known-malicious applications observed in BEC, AiTM and consent-phishing campaigns.
Run an OAuth abuse case
from consent to closure.
Three operator-grade playbooks built for SOC, IR and threat hunters: how to evict a malicious app from a tenant, where to find every forensic trace it leaves behind, and how to detect the next one before consent is granted.
OAuth attack tradecraft against Entra ID
A field guide to how attackers actually use OAuth against Microsoft tenants in 2024-2026, organised by phase. Every technique here has been documented in public incident reports - the references are linked. Read it as the attacker's playbook so the rest of this tab (forensics, detections, hunting) makes sense in context.
1 Initial access - getting a token in the first place
Consent phishing (classic)
Attacker registers a multi-tenant app in their own tenant, sends a consent URL to the victim. Victim clicks, authenticates against the real Microsoft login, accepts the consent prompt. Service principal is created in the victim's tenant; attacker now has a refresh token. Highest-volume variant; aggregated lists are the basis of OAuthSentry's malicious feed.
- Common scopes requested:
Mail.Read,Mail.ReadWrite,Files.Read.All,offline_access,User.Read. - Homoglyph display names (Cyrillic
оinstead of Latinoin "OneDrive") are common - 9 entries in the Entra dataset are confirmed homoglyph apps, preserved verbatim as IOCs. - Real campaigns: Wiz's 2025 campaign (19 apps spoofing OneDrive/DocuSign/Adobe), RH-ISAC/Proofpoint MFA-themed phishing.
Device code phishing (Storm-2372 / APT29 / UTA0304 / UTA0307)
Attacker initiates a device-code flow against any first-party client (most often Microsoft Authentication Broker 29d9ed98-a469-4536-ade2-f981bc1d605e). Microsoft returns an 8-character user code and a verification URL. Attacker DMs/emails the victim with a pretext ("enter this code to join the meeting") and the victim authenticates on Microsoft's real login portal. Attacker polls the token endpoint and receives the access token + family refresh token.
- Sign-in log fingerprint:
AuthenticationProtocol = deviceCode+OriginalTransferMethod = deviceCodeFlow. - Microsoft began rolling out blocks on device-code flow via Conditional Access in 2024; verify your tenant has the block in place unless you have a specific need.
OAuth code phishing via first-party apps (UTA0352)
March 2025 onward. Attacker uses Volexity-tracked aebc6443-996d-45c2-90f0-388ff96faa56 (Visual Studio Code) with redirect URI https://insiders.vscode.dev/redirect or https://vscode-redirect.azurewebsites.net. Victim authenticates, gets redirected to in-browser VS Code which displays the auth code; attacker extracts and exchanges for token. No malicious app, no consent prompt, no novel infrastructure - everything terminates on Microsoft domains.
AiTM / token theft (Evilginx, Mamba2FA)
Reverse-proxy phishing kits sit between the victim and the real Microsoft login, intercepting the session cookie and refresh token after MFA completes. Token theft accounted for an estimated 31% of M365 breaches in 2025. The stolen token has the same MFA claim as the legitimate sign-in, so a one-time replay from a different IP looks like a normal non-interactive sign-in.
2 Privilege escalation - getting more than the token grants
Service principal credential backdoor (Midnight Blizzard / Solorigate)
The attacker enumerates application objects and service principals. For any SP they can write to (because they own the app, or have Application.ReadWrite.All, or the SP has the microsoft.directory/servicePrincipals/credentials/update right), they add their own client secret or X.509 certificate. They now authenticate via OAuth 2.0 client-credentials flow as that app, completely outside any user context, MFA, or interactive Conditional Access.
App ownership abuse ("Misowned and Dangerous", Semperis 2025)
The attack path documented in EntraGoat. A compromised low-privileged user discovers they own an enterprise application that has a privileged role assignment. The owner can add a client secret to the SP without any admin role. They then auth as the app, use its role to reset a Global Administrator's password, issue a Temporary Access Pass (TAP), and log in interactively as the GA. Tenant compromise, no admin role required at the start.
Federated identity credential persistence
Attackers with sufficient permissions add a federated identity credential to a high-privilege application object pointing at an attacker-controlled IdP (e.g. https://attacker.example.com). They then exchange tokens from the attacker's IdP for Microsoft tokens via api://AzureADTokenExchange. No client secret to rotate, no certificate thumbprint to spot - just a small JSON record that looks legitimate. Subtle, durable, often missed in audits.
Actor Token Forgery (CVE-2025-55241)
Disclosed September 2025 by Dirk-jan Mollema. A flaw in the legacy Access Control Service let attackers forge "actor" tokens (used for service-to-service delegation) impersonating any user in any tenant, including Global Admins. Critically, the forged-token request generates no sign-in log entry - downstream Graph activity is the only trace. Microsoft patched in September 2025 but: (a) any pre-patch abuse is largely invisible after the fact, and (b) the class of bug (S2S token tenant validation gaps) is unlikely to be the last. Hunt for actor-token usage where the SP displayName is a Microsoft service but the userPrincipalName is a real user.
FOCI / family refresh token misuse
An undocumented Entra behaviour, researched by Secureworks. ~16 first-party Microsoft client IDs are part of a "family"; a refresh token issued to any one of them can be redeemed for an access token to any other. Steal a token from Azure CLI and you can request tokens for Teams, Outlook, OneDrive, Office, etc. - the union of every family scope. TeamFiltration (UNK_SneakyStrike, 80,000+ targeted accounts since Dec 2024) industrialised this.
Known FOCI app IDs from Secureworks' known-foci-clients.csv:
| App | App ID |
|---|---|
| Microsoft Azure CLI | 04b07795-8ddb-461a-bbee-02f9e1bf7b46 |
| Microsoft Azure PowerShell | 1950a258-227b-4e31-a9cf-717495945fc2 |
| Microsoft Teams | 1fec8e78-bce4-4aaf-ab1b-5451cc387264 |
| Microsoft Office | d3590ed6-52b3-4102-aeff-aad2292ab01c |
| OneDrive SyncEngine | ab9b8c07-8f02-4f72-87fa-80105867a763 |
| Outlook Mobile | 27922004-5251-4030-b22d-91ecd9a37ea4 |
| Microsoft Authenticator App | 4813382a-8fa7-425e-ab75-3b753aab3abb |
| Visual Studio | 872cd9fa-d31f-45e0-9eab-6e460a02d1f1 |
| Office 365 Management | 00b41c95-dab0-4487-9791-b9d2c32c80f2 |
| OneDrive iOS App | af124e86-4e96-495a-b70a-90f90ab96707 |
| Windows Search | 26a7ee05-5602-4d76-a7ba-eae8b7b67941 |
Adjacent to the FOCI list, but distinct: the Microsoft Authentication Broker (MAB, 29d9ed98-a469-4536-ade2-f981bc1d605e) is not on Secureworks' canonical FOCI list, but it is the client used by Windows for Entra device-join and is the favourite vector for Primary Refresh Token (PRT) phishing per Dirk-jan Mollema's research. UTA0355 and Storm-2372 both used MAB. Hunt MAB sign-ins with AuthenticationProtocol = deviceCode the same way you hunt the FOCI list - the playbook is the same, the family membership is not.
3 Persistence - staying after credentials change
Device registration to mint a Primary Refresh Token (UTA0355)
Once the attacker has an OAuth code, they call the Device Registration Service to register their own device against the victim's tenant. They then convince the victim to approve a 2FA prompt ("to access SharePoint for the conference"). The attacker now has a Primary Refresh Token (PRT) tied to a "compliant" device they control. PRTs survive password resets and most refresh-token revocations - the only reliable cleanup is unregistering the device.
Inbox rules + transport rules
Classic BEC. Forward-then-delete rules to hide replies during invoice fraud. Tenant-wide transport rules created via Graph after admin-consent abuse to siphon mail at the org level.
Conditional Access policy exclusions
Attacker (with admin or app-perm equivalent) modifies an existing CA policy to add an "exclusion" for a specific user or named location they control. Looks like a routine policy edit in the audit log; effect is a permanent bypass.
Cross-tenant access trust
Attacker who compromises a trusted partner tenant (B2B collaboration / cross-tenant access settings) can keep accessing the victim tenant's resources even after every credential in the victim tenant is reset.
4 Defense evasion - blending in
- Residential proxies (Spur.us-tracked): NSOCKS, 911, BrightProxies. Attacker traffic comes from IPs that also belong to legitimate users in the same neighbourhood.
- First-party apps: VSCode, Azure CLI, Office traffic blends into developer noise.
- CAE token extension: when CAE is enabled, attackers prefer to obtain CAE-aware tokens because they last 24-28h and skip mid-session re-auth - good for the attacker as long as the user/admin doesn't trigger an explicit revocation event.
- Throttling MailItemsAccessed: Microsoft caps MailItemsAccessed bind-event logging at 1,000 audit records per mailbox per 24 hours; once a mailbox crosses that threshold, bind events are not logged for the next 24 hours (other mailbox audit actions like Send, SoftDelete, and sync operations continue unaffected). The first 1,000 events ARE recorded - what gets silenced is follow-on bind activity in the next 24 hours, so attackers use this to hide a quiet "second pass" rather than the initial dump. Sync-based exfiltration (Outlook desktop dumping an entire folder) generates one record per folder and never trips the cap regardless of message count. Microsoft says <1% of mailboxes ever hit throttling, so an event with
IsThrottled = Trueis itself a high-fidelity IOC. - Removing audit trail: post-compromise, deleting the added credential or the new SP after using it. Only catches if your retention is long enough to look back before the cleanup.
5 The MITRE ATT&CK cloud mapping
For SOC ticketing, the techniques above map to:
| Tactic | Technique | What we covered |
|---|---|---|
| Initial Access | T1566.002 - Spearphishing Link | Consent / device-code / OAuth-code phishing |
| Initial Access | T1078.004 - Cloud Accounts | AiTM / token theft replay |
| Persistence | T1098.001 - Additional Cloud Credentials | SP credential backdoor, federated credential |
| Persistence | T1098.003 - Additional Cloud Roles | App role assignment / admin consent |
| Persistence | T1098.005 - Device Registration | UTA0355 device join + PRT |
| Privilege Escalation | T1078.004 - Cloud Accounts | App ownership pivot, Actor Token Forgery |
| Defense Evasion | T1550.001 - Application Access Token | FRT replay across FOCI apps |
| Credential Access | T1528 - Steal Application Access Token | Consent phishing harvest |
| Collection | T1114.002 - Remote Email Collection | MailItemsAccessed via OAuth |
| Exfiltration | T1567 - Exfiltration Over Web Service | Microsoft Graph as exfil channel |
Remediating a malicious OAuth consent grant
Use this playbook when a user has consented to a malicious OAuth application in Microsoft Entra ID. Order matters: revoke before you investigate, preserve evidence before you delete.
Revoke-MgUserSignInSession), the attacker can mint fresh access tokens for three months. The only mechanism that can invalidate a live access token before expiry is Continuous Access Evaluation (CAE) - and CAE only applies to CAE-aware resources (Exchange Online, SharePoint Online, Teams, Microsoft Graph) and CAE-aware clients. Resetting the user's password while tokens are still valid does not evict the attacker. Treat the window between containment and full eviction as live; assume continued read access until proven otherwise, and size that window using your tenant's actual CTL setting (audit via Get-MgPolicyTokenLifetimePolicy).
1 Preserve evidence before you change anything
The lede above says "preserve evidence before you delete" - this is the step that makes that real. Once you start disabling SPs, revoking tokens, and deleting grants, the tenant state changes. If you skip this step, you lose the ability to answer "what did the application object look like at the moment of compromise" months later when legal, insurance, or regulators ask.
- Snapshot the SP and all its credentials. Run the discovery query in Step 2 first, but pipe the output to a file rather than just the console - keep the raw JSON of
Get-MgServicePrincipal,Get-MgOauth2PermissionGrant, andGet-MgServicePrincipalAppRoleAssignmentso you have the unmodified record of what the consent looked like. - Capture the broader tenant state with EntraExporter. The
EntraExporterPowerShell module (nowmicrosoft/EntraExporter, originally by Merill Fernando):Install-Module EntraExporter, thenExport-Entra -Path .\<case-id> -Allwrites the entire tenant config to disk: applications, service principals, OAuth2 grants, app role assignments, conditional access policies, role assignments, named locations. This is the cleanest way to capture "the tenant at incident time" before remediation rewrites it. Treat the export as evidence: hash it, copy it off-host, and reference the hash in your incident timeline. - Pull and archive the relevant audit data. Your SIEM retention may be shorter than your investigation timeline - export the raw
Consent to application,Add OAuth2PermissionGrant,Add app role assignment to service principal, andAdd service principal credentialsevents for the malicious app id from the M365 unified audit log to a case archive. The Splunk Forensic Traces queries in the Investigation tab generate this set; pin the time range to the suspected exposure window plus 30 days on either side. - Note the snapshot timestamp in the case file. Everything that follows in this playbook is dated relative to "evidence captured at T0". Reviewers in three months need this anchor.
2 Identify scope of the consent
Pull every consent grant for the malicious application id across the tenant. Both delegated (per-user) and application (admin-consented) grants must be enumerated.
# Write scopes are required because steps 2-5 will disable, revoke, and remove
Connect-MgGraph -Scopes `
"Application.ReadWrite.All", `
"DelegatedPermissionGrant.ReadWrite.All", `
"AppRoleAssignment.ReadWrite.All", `
"Directory.AccessAsUser.All", `
"User.RevokeSessions.All"
$badAppId = "<malicious-application-id>"
# 1. Locate the service principal in this tenant
$sp = Get-MgServicePrincipal -Filter "appId eq '$badAppId'"
$sp | Format-List Id, AppId, DisplayName, AppOwnerOrganizationId, `
PublisherName, VerifiedPublisher, ServicePrincipalType, `
ReplyUrls, Tags, AccountEnabled, DisabledByMicrosoftStatus
# 2. List delegated grants (per-user "OAuth2PermissionGrants")
# consentType "Principal" = single user; "AllPrincipals" = tenant-wide admin consent
Get-MgOauth2PermissionGrant -Filter "clientId eq '$($sp.Id)'" |
Select-Object Id, ClientId, ConsentType, PrincipalId, ResourceId, Scope
# 3. List application-role grants (admin consent only, "AppRoleAssignments")
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id |
Select-Object Id, AppRoleId, PrincipalDisplayName, ResourceDisplayName, CreatedDateTime
# 4. List any client secrets / certificates the app added (post-consent persistence)
$sp.PasswordCredentials | Select-Object KeyId, DisplayName, StartDateTime, EndDateTime
$sp.KeyCredentials | Select-Object KeyId, DisplayName, StartDateTime, EndDateTime, Type
3 Disable the service principal
This stops new sign-ins and token requests immediately. Disabling is reversible if the verdict turns out to be wrong; deletion is not, and Entra retains a tombstone for 30 days regardless.
# Disable the service principal (single command, tenant-wide) Update-MgServicePrincipal -ServicePrincipalId $sp.Id -AccountEnabled:$false # Verify (Get-MgServicePrincipal -ServicePrincipalId $sp.Id).AccountEnabled
4 Revoke all refresh tokens for affected users
Revoke-MgUserSignInSession calls Microsoft Graph POST /users/{id}/revokeSignInSessions, which invalidates every refresh token and session token issued to that user across every app, not just the malicious one. This is the right hammer - the attacker's refresh token was probably scoped to a different app id than the one the user thinks they revoked, and you want all of them dead. The cmdlet returns immediately; replication to resource providers (Exchange, SharePoint) takes seconds for CAE-aware services and up to one hour for non-CAE replication.
# 1. Users who consented to this specific app (delegated grants)
$grantPrincipals = (Get-MgOauth2PermissionGrant -Filter "clientId eq '$($sp.Id)'").PrincipalId |
Where-Object { $_ } | Sort-Object -Unique
# 2. Anyone who signed in to the malicious app within the lookback window.
# Pull from sign-in logs to catch users who got tokens but were not in the grant list
# (e.g. AllPrincipals admin consent grants do not have a PrincipalId).
$signInUsers = Get-MgAuditLogSignIn `
-Filter "appId eq '$badAppId' and createdDateTime ge 2026-04-01T00:00:00Z" `
-All | Select-Object -ExpandProperty UserId | Sort-Object -Unique
$affectedUsers = ($grantPrincipals + $signInUsers) | Sort-Object -Unique
foreach ($uid in $affectedUsers) {
Write-Host "Revoking sessions for $uid"
Revoke-MgUserSignInSession -UserId $uid
}
5 Remove every consent grant
Disabling the SP blocks new tokens but leaves the consent records in place; remove them so the app cannot be re-enabled silently and so audit reporting stays accurate.
# Remove delegated permission grants
Get-MgOauth2PermissionGrant -Filter "clientId eq '$($sp.Id)'" |
ForEach-Object { Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $_.Id }
# Remove application role assignments (admin-consented permissions)
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id |
ForEach-Object {
Remove-MgServicePrincipalAppRoleAssignment `
-ServicePrincipalId $sp.Id `
-AppRoleAssignmentId $_.Id
}
6 Block the app from being re-consented
If the app is registered in another tenant (multi-tenant, AppOwnerOrganizationId is not your tenant), tenant-wide consent restrictions and a tenant-level block prevent re-introduction.
# Tenant-wide hard-disable of the service principal
Update-MgServicePrincipal -ServicePrincipalId $sp.Id `
-AccountEnabled:$false `
-Tags @("HideApp","BlockedFromConsent")
# Restrict end-user consent globally (do this once tenant-wide, not just for this app)
Update-MgPolicyAuthorizationPolicy `
-DefaultUserRolePermissions @{ permissionGrantPoliciesAssigned = @() }
disabledByMicrosoftStatus property; report to MSRC and CISA so the app is taken down for everyone.
7 Investigate what the app accessed
Pivot to the Forensic traces tab. The questions you need answered: which mailboxes did it read, did it send mail, did it create inbox rules, did it modify MFA, did it register a device.
Mail.ReadWrite, Files.Read.All, Directory.Read.All) but not what specific Graph endpoints that scope unlocks. Use Merill Fernando's graphpermissions.merill.net to reverse-look-up each scope into the exact list of Graph endpoints and resource types it grants - this is what you tell legal/compliance the attacker had access to. Combine that with the actual Graph activity log records (see Forensics tab section 5) to narrow "could have accessed" to "did access". Knowing the difference between "Mail.Read on one mailbox" and "Mail.ReadWrite tenant-wide" is the difference between a one-user incident and a regulatory disclosure.
8 User communication & post-incident
- Notify affected users Tell them the app was disabled, that a password reset alone would not have stopped the attacker, and that the access has been revoked.
- Force password reset only after revoking refresh tokens. Resetting first while tokens are still valid does not evict the attacker.
- Re-enroll MFA if there is any indication the attacker added their own MFA method.
- Review and tighten consent policy Move to admin-consent workflow for any app requesting Mail.*, Files.*, or Directory.* scopes.
- Add the App ID to your watchlist Push the OAuthSentry malicious feed into your SIEM so the next consent attempt alerts immediately.
Microsoft Graph REST equivalent (one-liner per step)
| Step | HTTP request |
|---|---|
| Find SP | GET /v1.0/servicePrincipals?$filter=appId eq '<guid>' |
| Disable SP | PATCH /v1.0/servicePrincipals/<sp-id> { "accountEnabled": false } |
| List grants | GET /v1.0/oauth2PermissionGrants?$filter=clientId eq '<sp-id>' |
| Revoke grant | DELETE /v1.0/oauth2PermissionGrants/<grant-id> |
| Revoke sessions | POST /v1.0/users/<user-id>/revokeSignInSessions |
Forensic traces of OAuth abuse
Every step of an OAuth consent attack leaves an artifact somewhere in Entra ID, Microsoft Graph activity logs, or the user's mailbox metadata. This is the operator's map of where to look.
1 Application registration & service principal creation
The first event is creation of the service principal in your tenant - the moment the app appears as a "thing" Entra knows about. All of the operations below appear in either the M365 unified audit log (Splunk macro oauthsentry_o365_audit, RecordType = AzureActiveDirectory) or the Entra audit log streamed via Azure Monitor diagnostic settings (macro oauthsentry_aad_audit). The Operation field is what to filter on.
appOwnerOrganizationId field gives the answer if you cross-check against Microsoft's tenant ids - and Merill Fernando's merill/microsoft-info repository (aka.ms/AppNames) maintains a daily-refreshed CSV/JSON of every known Microsoft first-party AppId, display name, and owner tenant. Pull MicrosoftApps.csv as a Splunk lookup (| inputlookup microsoft_apps.csv) and join it on appid; any SP active in your tenant whose AppId is NOT in that list AND whose appOwnerOrganizationId matches a Microsoft tenant id is suspicious by definition. Merill also publishes GraphAppRoles.csv and GraphDelegateRoles.csv for translating Graph permission GUIDs (the values you see in ModifiedProperties.NewValue) back into human-readable scope names.
| Source | Operation / field | What it tells you |
|---|---|---|
| Audit log op | Add service principal | SP appeared in your tenant. Captures app id, displayName, initiator. First event in the chain for any new app. |
| Audit log op | Add service principal credentials | Client secret or certificate added to the SP - very common attacker persistence step (e.g. Midnight Blizzard added their own creds to existing SPs). |
| Audit log op | Update application - Certificates and secrets management | Cert/secret added on the application object (vs the SP). Same intent, slightly different code path. |
| Audit log op | Consent to application | User-level consent prompt accepted. ConsentContext.IsAdminConsent and the scope string in ConsentAction.Permissions are the key fields. |
| Audit log op | Add OAuth2PermissionGrant | The actual delegated permission grant object created. Often appears alongside Consent to application; also fires when admin grants directly via Graph (POST /oauth2PermissionGrants). |
| Audit log op | Add delegated permission grant | Variant name surfaced by some log pipelines for the same underlying event. Hunt both strings. |
| Audit log op | Add app role assignment to service principal | App-level (admin-consent) permission granted - much higher impact. Anything in this stream that contains .All, Mail., Files., Directory. deserves immediate review. |
| SP object | appOwnerOrganizationId | Tenant that owns the app. If not your tenant id, it is multi-tenant; cross-reference with Microsoft's first-party tenants f8cdef31-a31e-4b4a-93e4-5f571e91255a and 72f988bf-86f1-41af-91ab-2d7cd011db47. |
| SP object | verifiedPublisher | Empty for nearly all malicious apps. Verified publishers are a strong negative IOC. |
| SP object | disabledByMicrosoftStatus | DisabledDueToViolationOfServicesAgreement means Microsoft has globally tombstoned the app. If you see this, treat it as confirmed malicious. |
| SP object | replyUrls / homepage | Where Entra redirects auth codes. Attacker-controlled domains here are decisive evidence; for UTA0352 watch for insiders.vscode.dev and vscode-redirect.azurewebsites.net. |
| SP object | passwordCredentials / keyCredentials | Any new entry with a recent startDateTime = attacker minted their own credential. |
| SP object | tags | WindowsAzureActiveDirectoryIntegratedApp is normal; HideApp, unusual or attacker-script-injected values are not. |
2 The consent event itself
This is the event everyone hunts for. In Entra audit logs:
{
"operationName": "Consent to application",
"category": "ApplicationManagement",
"result": "success",
"initiatedBy": { "user": { "userPrincipalName": "victim@org.com" } },
"targetResources": [
{
"id": "<service-principal-id>",
"type": "ServicePrincipal",
"displayName": "Adobe Drive X",
"modifiedProperties": [
{ "displayName": "ConsentAction.Permissions",
"newValue": "Scope=Mail.Read Mail.Send offline_access" },
{ "displayName": "ConsentContext.IsAdminConsent", "newValue": "False" },
{ "displayName": "TargetId.ServicePrincipalNames",
"newValue": "<application-id-guid>" }
]
}
]
}
Pivot fields: the application id, scopes consented, IsAdminConsent flag (admin = much higher impact), and the user principal who clicked.
operationName, targetResources[].modifiedProperties[]) - that's what you get if you stream Entra diagnostic logs to Azure Monitor or Log Analytics. The Microsoft 365 unified audit log emits the same event with a different shape (PascalCase, flatter ModifiedProperties[] at the top level) and different field names. The OAuthSentry SPL macros target the M365 unified shape, because that's what the Splunk Add-on for Microsoft Office 365 ingests via the Management Activity API. Below is the real M365 unified shape of a single Consent to application. event (anonymized) - match this against your raw logs to confirm the field paths the SPL queries pivot on.
{
"CreationTime": "2026-04-27T07:20:47",
"Id": "<event-uuid>",
"Operation": "Consent to application.",
"OrganizationId": "<your-tenant-id>",
"RecordType": 8,
"ResultStatus": "Success",
"Workload": "AzureActiveDirectory",
"AzureActiveDirectoryEventType": 1,
"ObjectId": "<consented-app-id>", ``` AppId of the app being consented to ```
"UserId": "admin.consenting@yourtenant.onmicrosoft.com", ``` UPN of the user who clicked ```
"UserKey": "<puid>@yourtenant.onmicrosoft.com",
"ExtendedProperties": [
{ "Name": "additionalDetails",
"Value": "{\"User-Agent\":\"Mozilla/5.0 (Windows NT 10.0; ...) Chrome/<ver> Safari/537.36\",\"AppId\":\"<consented-app-id>\",\"ServicePrincipalProvisioningType\":\"Other\"}" },
{ "Name": "extendedAuditEventCategory", "Value": "ServicePrincipal" }
],
"ModifiedProperties": [
{ "Name": "ConsentContext.IsAdminConsent", "NewValue": "True", "OldValue": "" },
{ "Name": "ConsentContext.IsAppOnly", "NewValue": "False", "OldValue": "" },
{ "Name": "ConsentContext.OnBehalfOfAll", "NewValue": "True", "OldValue": "" },
{ "Name": "ConsentContext.Tags", "NewValue": "", "OldValue": "" },
{ "Name": "ConsentAction.Permissions",
"NewValue": "[[Id: <grant-id>, ClientId: <sp-objectid>, PrincipalId: , ResourceId: <resource-sp-id>, ConsentType: AllPrincipals, Scope: User.Read, CreatedDateTime: , LastModifiedDateTime ]] => [[Id: <grant-id>, ClientId: <sp-objectid>, PrincipalId: , ResourceId: <resource-sp-id>, ConsentType: AllPrincipals, Scope: User.Read, CreatedDateTime: , LastModifiedDateTime ]];",
"OldValue": "" }
],
"Actor": [
{ "ID": "admin.consenting@yourtenant.onmicrosoft.com", "Type": 5 }, ``` Type 5 = UPN ```
{ "ID": "<puid>", "Type": 3 }, ``` Type 3 = PUID ```
{ "ID": "Microsoft_AAD_RegisteredApps", "Type": 1 }, ``` Type 1 = source app/portal ```
{ "ID": "<user-objectid>", "Type": 2 }
],
"Target": [
{ "ID": "ServicePrincipal_<sp-objectid>", "Type": 2 },
{ "ID": "<sp-objectid>", "Type": 2 }, ``` SP ObjectId in tenant ```
{ "ID": "ServicePrincipal", "Type": 2 },
{ "ID": "<App display name as it appeared at consent time>", "Type": 1 },
{ "ID": "<consented-app-id>", "Type": 4 } ``` AppId again, Type 4 ```
],
"ActorContextId": "<your-tenant-id>",
"TargetContextId": "<your-tenant-id>"
}
The four fields the OAuthSentry SPL pivots on, with paths confirmed against the shape above:
- AppId of the consented app →
ObjectIdat the top level (also redundantly available asExtendedProperties[].additionalDetails.AppIdandTarget[]entries withType=4). TheTargetId.ServicePrincipalNamesfield that some older detections key on does not exist in the M365 unified shape - it lives only in the Azure AD diagnostic stream. - Admin vs user consent →
ModifiedProperties[]whereName=="ConsentContext.IsAdminConsent", value is the string"True"/"False". - Granted scopes →
ModifiedProperties[]whereName=="ConsentAction.Permissions". The value is a bracket-delimited string, not JSON; one or more[[Id: ..., ClientId: ..., ResourceId: ..., ConsentType: ..., Scope: SCOPE_NAME, ...]]groups separated by;, with anold => newarrow showing the diff. Multi-scope consents emit multiple groups;regex max_match=20againstScope:\s*(?<scope>[^,\]]+)extracts each. - User-Agent at consent time →
ExtendedProperties[]whereName=="additionalDetails". TheValuefield is a JSON-encoded string (escaped quotes);spath input=Valuedrills in toUser-Agentand the redundantAppId. Hunt forpython-requests,curl,ROADtools, headless browsers - they're rarely present on legitimate consent prompts.
3 Sign-in logs - the app actually using its tokens
Once consent is granted the app starts signing in on behalf of the user. These appear in service principal sign-in logs, not interactive sign-ins. The Splunk macro stack splits them three ways: oauthsentry_aad_sp_signin for the SP authenticating as itself (category = ServicePrincipalSignInLogs on the underlying records), oauthsentry_aad_signin with category = SignInLogs for interactive sign-ins (the user clicking the consent prompt), and the same macro with category = NonInteractiveUserSignInLogs for token refreshes on behalf of the user.
| Field | What to hunt for |
|---|---|
| AppId | Match against the OAuthSentry malicious or risky feed. |
| ResourceDisplayName / ResourceId | What was accessed. 00000003-0000-0000-c000-000000000000 = Microsoft Graph; 00000002-0000-0ff1-ce00-000000000000 = Exchange Online; 00000003-0000-0ff1-ce00-000000000000 = SharePoint. |
| IPAddress / Location | Source. Cross-check against the user's normal sign-in pattern; residential proxies (Spur.us, GreyNoise) are common in UTA0352/APT29 ops. |
| UserAgent | python-requests, curl, ROADtools, Mozilla/5.0 (X11; Linux ...) against a normally-Windows mailbox = attacker tooling. |
| IncomingTokenType | refreshToken after the first sign-in. Long sequences of refresh-token grants from one IP = active attacker session. |
| ResultType | 0 = success. Filter on this when correlating with downstream activity. |
| AuthenticationProtocol | deviceCode = device-code phishing (Storm-2372). authCode with first-party app id = UTA0352 pattern. |
| ConditionalAccessStatus | notApplied = no policy matched, often an indicator the attacker found a CA gap. failure = blocked. |
| "Continuous access evaluation" | Field on sign-in details indicating whether the issued token is CAE-aware. true means revocation will work near-real-time; false means the token must run out the clock. |
4 Linkable identifiers - the cross-log correlation key
Three identifiers are stamped on every Microsoft-issued token and propagate into every downstream log. These are how you turn "this user signed in" into "and here is every Exchange operation, every Graph call, every SharePoint hit that token authorized." Capture them from the sign-in log entry, then pivot.
| Identifier | Aliases in logs | Granularity | What it links |
|---|---|---|---|
| SessionId (SID) | SessionId on Entra sign-in events; AADSessionId on the AppAccessContext object in M365 unified audit log; SessionId field directly in Exchange mailbox audit records since 2019 | One sign-in session | Every action that token-chain authorized: mailbox reads, file accesses, Graph calls. The single most useful field for OAuth investigations. |
| UniqueTokenIdentifier (UTI) | uniqueTokenIdentifier on Entra SP sign-in events; uti claim in the JWT; surfaces in Microsoft Graph activity logs via the same name | One specific access token | Pinpoints which token did exactly which action. Use this when you need to tell apart actions by the same user from two different sessions. |
| CorrelationId | correlationId across Entra sign-in logs, Entra audit logs, and the M365 unified audit log | One request chain | Ties together the consent event, the resulting SP sign-in, and the audit-log changes that flow from it. |
SessionId is an Entra ID construct that only exists when the user authenticates via modern auth (OAuth 2.0 / ADAL / MSAL). Legacy basic-auth sessions have no SessionId. If your tenant still permits legacy auth, the absence of a SessionId on a mailbox audit event is itself a signal worth investigating.
Tracking a token when you don't have the username
Real IR rarely starts with "user X is compromised, find their actions". It usually starts with "we have this indicator from somewhere - what did it touch?" The indicator is a token artifact (a UTI, a hashed token, a session id), and the username is what you're trying to derive from it. Five concrete workflows, ordered by how often they come up:
1. You have a UTI from a Graph activity log entry. The UTI in properties.signInActivityId is the same value as the uti JWT claim and the uniqueTokenIdentifier field on Entra sign-in events. Walk it backwards to recover the user, then forward to find every other action that token authorized:
``` Step 1: find the originating sign-in (UTI is unique per token, ~75 min lifetime). ```
``` This recovers userPrincipalName, source IP, device, MFA factor, IdP, conditional ```
``` access result - everything you need to scope the actor. ```
`oauthsentry_aad_signin` earliest=-7d
| where uniqueTokenIdentifier="AAAAExampleUtiXXXXXXXX"
| table _time, userPrincipalName, userId, appDisplayName, appId, ipAddress,
location.city, deviceDetail.operatingSystem, authenticationDetails{}.authenticationMethod,
conditionalAccessStatus
``` Step 2: find every Graph call made with that token. Same UTI, different stream. ```
`oauthsentry_graph_activity` earliest=-7d
| where 'properties.signInActivityId'="AAAAExampleUtiXXXXXXXX"
| table _time, 'properties.requestMethod', 'properties.requestUri',
'properties.responseStatusCode', 'properties.responseSizeBytes'
| sort _time
``` Step 3: M365 unified audit log entries authorized by tokens that share the ```
``` same SESSION (a session can mint many tokens). The session lives longer than ```
``` any one token, so this is the broader scope. ```
`oauthsentry_o365_audit` earliest=-7d
| where like('AppAccessContext.AADSessionId', "<session-id-from-step-1>")
| table _time, Operation, ObjectId, UserId, ClientIP, AppId
2. You have a GitHub hashed_token from one audit log event. GitHub publishes the SHA-256 hash of every issued token in the audit log. GitHub's hashed-token search takes the hash directly and returns every event - clones, fetches, API calls, repo reads - made by tokens that produced that hash:
``` Take the hashed_token value from one event, find every event with the same hash. ```
``` Returns every action that specific token took, regardless of which user owns it. ```
HASH="ExAmPLEhAsHEdT0kEnVaLuEf0rD0cs00xx00000000="
ENC=$(printf %s "$HASH" | jq -sRr @uri)
curl -s \
-H "Authorization: Bearer $PAT" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/orgs/$ORG/audit-log?phrase=hashed_token:$ENC&per_page=100" \
| jq '.[] | {ts: .["@timestamp"], action, user, actor_ip, repo, oauth_application_name}'
3. You have a token captured from a phishing kit, stealer log, or beacon. Decode the JWT (it's three base64-url segments separated by dots; the middle segment is JSON). The uti claim is what you pivot on - same value as the audit-log fields above. The oid claim gives you the user object id, scp lists the granted scopes, iat / exp give you the validity window:
TOKEN="eyJ0eXAiOi..." # paste the access_token portion only
``` Decode the middle segment. Bash trick: replace -_/+/= padding, base64 decode. ```
echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq '{
uti, oid, tid, appid, app_displayname,
iat: (.iat | todate), exp: (.exp | todate),
scope: .scp,
upn: (.upn // .preferred_username // "(no upn claim)"),
ipaddr
}'
``` The 'uti' value plugs straight into the queries in workflow 1 above to find ```
``` everything that token did inside your tenant. ```
4. You have a Splunk SessionId but no clue who it belongs to. The SessionId appears on Entra sign-in events, mailbox audit events, and inside the AppAccessContext on every M365 unified audit log entry that involved an OAuth-authorized action. One query against any of those streams resolves the user:
``` Try the sign-in log first - cleanest mapping ```
`oauthsentry_aad_signin` SessionId="<session-id>"
| stats values(userPrincipalName) as upns
values(userId) as user_oids
values(appId) as app_ids
values(appDisplayName) as apps
values(ipAddress) as src_ips
earliest(_time) as first_seen
latest(_time) as last_seen
``` If no hit there, try the M365 audit log - the SessionId lives nested under ```
``` AppAccessContext (delegated tokens) or directly on Exchange mailbox events. ```
`oauthsentry_o365_audit` ('AppAccessContext.AADSessionId'="<session-id>" OR SessionId="<session-id>")
| stats values(UserId) as upns dc(Operation) as ops earliest(_time) as first_seen latest(_time) as last_seen
5. You have an OAuth Application's AppId but want all tokens issued under it. Every access token under one OAuth app shares the same AppId; what differs is the UTI per token and the SessionId per session. List the unique UTIs the app has minted in your tenant, and you have the full set of distinct tokens it has issued (and therefore the full set of users who consented or signed in via it):
`oauthsentry_aad_signin` appId="<app-id>" earliest=-30d
| stats values(uniqueTokenIdentifier) as token_utis
dc(uniqueTokenIdentifier) as distinct_tokens
values(userPrincipalName) as users
dc(userPrincipalName) as distinct_users
values(SessionId) as sessions
dc(SessionId) as distinct_sessions
earliest(_time) as first_seen
latest(_time) as last_seen
by appId
6. You have an app-only token's traces and need to follow the service principal (no user exists). App-only tokens (C_Idtyp == "app" in Graph activity logs) have an empty userId; the principal is the service principal itself. The pivot key is servicePrincipalId, and the cross-stream join on UTI still works the same way as for delegated flows. Common case: a vendor like Salesloft Drift discloses a compromise of one of their app registrations - you have an appId from the disclosure, you want every Graph call any of that app's tokens made against any user mailbox in your tenant. Two independent queries (run sequentially):
``` Returns one row per (servicePrincipalId, target user object-id) pair, with ```
``` the endpoints touched, distinct token count, and status codes seen. ```
``` Copy the targeted_user_oid values out and feed them to step 2 below. ```
`oauthsentry_graph_activity` earliest=-7d
| eval req_appid = 'properties.appId'
| eval req_spid = 'properties.servicePrincipalId'
| eval req_uri = 'properties.requestUri'
| eval req_uti = 'properties.signInActivityId'
| eval req_idtyp = 'properties.C_Idtyp'
| eval req_status = 'properties.responseStatusCode'
| where lower(req_appid) = "<app-id-from-disclosure>"
| where req_idtyp = "app"
``` Pull the target user object id out of /users/{id}/... requestUris ```
| rex field=req_uri "(?i)/v1\.0/users/(?<targeted_user_oid>[0-9a-f-]{36})"
| stats count
values(req_uri) as endpoints
values(req_uti) as token_utis
dc(req_uti) as distinct_tokens
values(targeted_user_oid) as targeted_user_oids
dc(targeted_user_oid) as distinct_users_targeted
values(req_status) as status_codes
earliest(_time) as first_seen
latest(_time) as last_seen
by req_spid
``` Run after step 1. Replace <oid1>, <oid2>, ... with the targeted_user_oid ```
``` values from step 1's output. Pulls a single audit-log row per user that gives ```
``` you the displayName and userPrincipalName. ```
`oauthsentry_aad_audit` earliest=-30d
targetResources{}.id IN ("<oid1>", "<oid2>", "<oid3>")
| dedup targetResources{}.id
| table targetResources{}.id, targetResources{}.displayName, targetResources{}.userPrincipalName
5 Microsoft Graph activity logs - the post-token-theft visibility layer
Graph activity logs are the only place that records every individual Graph API call the malicious app made. Without this stream, you can prove the app got a token but not what it did with it - which is the difference between "we were targeted" and "this is the regulator-disclosable scope of the breach". Stream them via Azure Monitor diagnostic settings (category MicrosoftGraphActivityLogs); in Splunk they land under the dedicated oauthsentry_graph_activity macro defined in the Detections tab.
An anonymized reference event - what one Graph activity record actually looks like in a French-region tenant after a delegated GET against /v1.0/users?$top=2000:
{
"time": "2026-04-27T21:30:08.296Z",
"resourceId": "/TENANTS/<tenant-id>/PROVIDERS/MICROSOFT.AADIAM",
"operationName": "Microsoft Graph Activity",
"operationVersion": "v1.0",
"category": "MicrosoftGraphActivityLogs",
"resultSignature": "400",
"durationMs": "488000",
"callerIpAddress": "<source-ip>",
"correlationId": "<correlation-id>",
"level": "Informational",
"location": "France Central",
"properties": {
"timeGenerated": "2026-04-27T21:30:08.296Z",
"requestId": "<request-id>",
"operationId": "<operation-id>",
"clientRequestId": "<client-request-id>",
"apiVersion": "v1.0",
"requestMethod": "GET",
"responseStatusCode": 400,
"tenantId": "<tenant-id>",
"durationMs": 488000,
"responseSizeBytes": 286,
"signInActivityId": "<uti>",
"appId": "d3590ed6-52b3-4102-aeff-aad2292ab01c", ``` Microsoft Office (FOCI app) ```
"UserPrincipalObjectID": "<user-objectid>",
"scopes": "Mail.ReadWrite Mail.Send Files.ReadWrite.All Directory.Read.All Directory.AccessAsUser.All Group.ReadWrite.All User.ReadWrite ... (~45 scopes)",
"wids": "b79fbf4d-3ef9-4689-8143-76b194e85509", ``` default-user wid: regular non-admin user ```
"userId": "<user-objectid>",
"userAgent": "User-Agent: Mozilla/5.0 (compatible, MSIE 11, Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko",
"ipAddress": "<source-ip>",
"requestUri": "https://graph.microsoft.com/v1.0/users?$top=2000&$select=id,displayName,mail,userPrincipalName,accountEnabled",
"policyEvaluated": false,
"tokenIssuedAt": "2026-04-27T13:19:23Z"
},
"tenantId": "<tenant-id>"
}
The same event shape, but for an app-only token reading another user's mailbox content - the post-vendor-compromise pattern that drives most of OAuthSentry's malicious feed (Salesloft Drift, Context.ai etc. all fall in this category):
{
"time": "2026-04-28T14:44:12.562Z",
"resourceId": "/TENANTS/<tenant-id>/PROVIDERS/MICROSOFT.AADIAM",
"operationName": "Microsoft Graph Activity",
"category": "MicrosoftGraphActivityLogs",
"resultSignature": "200", ``` success ```
"callerIpAddress": "<source-ip>", ``` AWS eu-west-1 in this real example ```
"location": "North Europe",
"properties": {
"requestMethod": "GET",
"responseStatusCode": 200,
"responseSizeBytes": 11785, ``` ~12 KB returned ```
"signInActivityId": "<uti>",
"appId": "<app-id>", ``` custom OAuth app, not a FOCI app ```
"servicePrincipalId": "<sp-objectid>", ``` app-only: SP is the principal ```
"UserPrincipalObjectID": "<sp-objectid>", ``` same as servicePrincipalId on app-only ```
"userId": "", ``` empty - no user ```
"scopes": "", ``` empty - app-only tokens have no scopes ```
"roles": "Mail.Read MailboxSettings.Read User.Read.All", ``` granted application permissions ```
"wids": "0997a1d0-0d1d-4acb-b408-d5ca73121e90", ``` Directory Readers - typical sole wid for an SP with no other roles ```
"C_Idtyp": "app", ``` THE single-best delegated-vs-app-only marker ```
"C_Sid": "", ``` empty - no user session ```
"C_DeviceId": "", ``` empty - no device ```
"userAgent": "Python/3.11 aiohttp/3.13.4", ``` async Python HTTP client - tooling UA ```
"ipAddress": "<source-ip>",
"requestUri": "https://graph.microsoft.com/v1.0/users/<target-user-objectid>/messages/<message-id>/$value",
"policyEvaluated": false,
"tokenIssuedAt": "2026-04-28T14:18:56Z" ``` token 26 min old when used ```
},
"tenantId": "<tenant-id>"
}
Five things this app-only event tells a defender, contrasting with the delegated event above:
- This is an app-only flow, not delegated. The single highest-fidelity tell is
properties.C_Idtyp == "app"- the same JWTidtypclaim. Reinforced byscopesbeing empty (app-only tokens have roles, not scopes) androlesbeing populated with the granted application permissions instead. Every detection and pivot for app-only flows must useservicePrincipalIdinstead ofuserId- the latter is empty. - The app token is reading another user's specific email by object-id and message-id.
requestUrimatches/v1.0/users/{user-objectid}/messages/{message-id}/$value. The/$valuesuffix returns raw MIME (full email including attachments), which is the most data-bearing form of mail read in the Graph API. App-onlyMail.Readcan read any mailbox in the tenant - this is the threat surface that Salesloft Drift, Context.ai and similar publisher-compromise scenarios exploited. - Source is in published Amazon EC2 IP space. The
34.x.x.xblocks are unambiguously AWS - cross-check the specific region against the live ip-ranges.json (in this real example the address resolves toeu-west-1/ Ireland, but exact ranges shift over time so always verify against the current published list). Many legitimate enterprise mail-reading SaaS products run on AWS - eDiscovery, archiving, journaling, security-scanning vendors. The legitimacy question is whether the specific SP shown here has a documented business reason to read mail from AWS infrastructure. Build a per-tenant allowlist of (servicePrincipalId, source-ASN) pairs that map to known mail-reading vendors; alert on any other combination. - User-agent is a Python async HTTP client.
Python/3.11 aiohttp/3.13.4is async Python. Real enterprise mail products almost always identify themselves with a vendor-branded UA.aiohttpshows up in attacker tooling and in homegrown SOC scripts in roughly equal measure - context determines which. - Token is fresh (~26 min old).
tokenIssuedAtat 14:18:56, event at 14:44:12 - well within the default 60-90 minute access-token lifetime. Recent token issuance combined with mailbox-content reads is consistent with active operation rather than legacy automation that just hasn't been touched in months.
How to tell delegated and app-only flows apart in the Graph activity log, at a glance:
| Field | Delegated (user-on-behalf) | App-only (service principal) |
|---|---|---|
| properties.C_Idtyp | user | app |
| properties.scopes | populated (space-separated scope strings like Mail.Read User.Read.All) | empty string |
| properties.roles | typically empty | populated with granted application permissions |
| properties.userId | the acting user's object id | empty / absent |
| properties.UserPrincipalObjectID | the acting user's object id | the service principal's object id (same as servicePrincipalId) |
| properties.servicePrincipalId | absent | populated (the principal) |
| properties.C_Sid | the user's session id | empty (no user session) |
| properties.C_DeviceId | often populated | empty |
| properties.wids | user's role list; b79fbf4d-... = default-user (no admin role) | SP's role list; 0997a1d0-... = Directory Readers (the typical sole wid when the SP has no other directory roles assigned) |
| requestUri shape | often /me/... for the acting user's own data | almost always /users/{id}/... or /groups/{id}/... - app reads any user's data |
Five things this single event tells a defender, in order of fidelity:
- Token came from a FOCI client.
properties.appId == d3590ed6-52b3-4102-aeff-aad2292ab01cis Microsoft Office, one of the eleven Family of Client IDs apps. Microsoft Office talking to/usersis firmly outside its normal traffic shape - Office hits/me/messages,/me/drive,/me/calendar,/me/joinedTeams, never/usersat scale. Off-pattern FOCI traffic is the post-token-theft signature TeamFiltration / UTA0352 / UNK_SneakyStrike rely on. - Directory enumeration intent (T1087.004).
requestUricontains/v1.0/users?$top=2000&$select=id,displayName,mail,userPrincipalName,accountEnabled- a textbook recon query: pull every account, who's enabled, enough metadata to pivot. The request returned 400 because Graph caps$topat 999 for/usersand high page sizes needConsistencyLevel: eventual; the attacker's next attempt would be$top=999with the right header. Alert on the attempt, not the result. - Massive scope set on a delegated token. The
scopesfield carries ~45 distinct scopes including multiple*.ReadWrite.All. A normal Microsoft Office client doesn't ship this much surface area in a single token; this looks like custom developer tooling (or attacker tooling impersonating a FOCI client). Tokens with >20 scopes including any*.ReadWrite.Allagainst a FOCI app id are anomalous on their own. - Acting user is a regular non-admin. The
widsclaim ofb79fbf4d-3ef9-4689-8143-76b194e85509is the default-user wid; it appears on every non-guest account and indicates an ordinary user with no admin role. A non-admin pulling 2000 users via Graph from an Office token is exactly the kind of "compromised account doing unusual recon" you should page on. - User-agent is wrong twice. First,
Mozilla/5.0 (...; MSIE 11, Windows NT 6.3; Trident/7.0; ...)is Internet Explorer 11 on Windows 8.1 - both end-of-life by 2023. Real Microsoft Office clients in 2026 don't ship that UA. Second, the literal value starts with the stringUser-Agent: User-Agent:(doubly-prefixed); this is a tooling bug where whatever made the request set the header value toUser-Agent: Mozilla/5.0...instead of justMozilla/5.0.... Both are weak signals on their own; doubly-prefixed UAs across a tenant are rare enough to hunt directly.
The full set of fields on every record:
properties.requestUri- which Graph endpoint was called (/v1.0/users,/v1.0/me/messages,/v1.0/me/drive/root/children, etc). The single most useful field for attack-class identification.properties.requestMethod- GET for read, POST/PATCH/DELETE for modification. Modification verbs against directory or app endpoints from a delegated user token are very rare in normal traffic.properties.responseStatusCode- 200/204 = data returned; 400 = malformed (often attacker first attempt); 401/403 = blocked by CA or scope; 429 = throttled (high-volume recon).properties.appId- the OAuth app id whose token authorized this call. Match against OAuthSentry's malicious or risky feeds, and check whether it's a FOCI app talking to off-pattern endpoints.properties.userId/properties.UserPrincipalObjectID- on whose behalf the call was made (delegated tokens) or null/empty for app-only flows.properties.scopes- the scopes carried by the token. Cross-reference against Merill's graphpermissions.merill.net to translate each scope into the exact list of endpoints it unlocks.properties.wids- well-known role IDs the user holds. Combined withuserId, this tells you whether the actor is a regular user or a privileged admin - which dramatically changes what "this Graph call is normal" means.properties.signInActivityId- the UTI claim of the token, the same identifier surfaced asuniqueTokenIdentifieron Entra sign-in events. The link back to the originating sign-in. Pivot via the section 4 table.properties.userAgent- the HTTP User-Agent header. Real Microsoft client UAs are well-known and stable per-app; anything else (Trident, python-requests, curl, Go-http-client, Java, doubly-prefixed) is suspicious.properties.ipAddress/callerIpAddress- source. Cross-check against the user's normal sign-in pattern.properties.tokenIssuedAt- when the token was originally issued. The delta betweentokenIssuedAtand the event time tells you whether the call was made with a fresh or a refresh-derived token, which sets your window of vulnerability after compromise. Microsoft's defaults: access tokens last a randomized 60-90 minutes (75 min average) for Conditional-Access-enabled tenants, or a flat 2 hours for clients like Microsoft Teams and Microsoft 365 in tenants without CA; refresh tokens last 90 days (24 hours for single-page apps), rolling-renewed on every use. Authorization codes (used only at the consent prompt) live exactly 10 minutes and are not configurable. If your tenant has a Configurable Token Lifetime (CTL) policy that setsAccessTokenLifetimeabove the default - up to the maximum 24 hours - that is itself a security finding: it widens the window during which a stolen access token remains valid and unrevocable (per the Remediation note on revocation limits). Audit your CTL policies viaGet-MgPolicyTokenLifetimePolicy; treat any policy withAccessTokenLifetimeabove two hours as worth a documented business justification.properties.responseSizeBytes- large responses on/messagesor/drive/...endpoints = bulk exfiltration.properties.policyEvaluated- whether Conditional Access evaluated this call. Many Graph operations don't trigger CA evaluation; this is normal but worth knowing during incident reconstruction.
MicrosoftGraphActivityLogs) and route them to whatever destination feeds your Splunk index. The Detections tab ships Detection 10 (FOCI-token directory enumeration) and the Hunting tab ships Hunts 9 and 10 specifically for this stream. Without retroactive logs, post-incident scope is largely guesswork.
6 Mailbox & artifact-level traces
For mail-scope abuse - the most common goal of OAuth phishing - Exchange has its own audit trail. The richest event is MailItemsAccessed, but it has caveats every investigator must know.
MailItemsAccessed moved from Purview Audit (Premium) to Purview Audit (Standard) - rolled out from June 2024 and complete by September 2024 - so it is now generated by default for any user on an Office 365 E3/E5 or Microsoft 365 E3/E5 license. Caveat: for mailboxes whose audit configuration was customized before the rollout, the new events are not auto-added; check (Get-Mailbox).DefaultAuditSet and add MailItemsAccessed to AuditOwner/AuditDelegate via Set-Mailbox if missing. Throttling: bind-event logging is capped at 1,000 audit records per mailbox per 24 hours; once exceeded, MailItemsAccessed bind events for that mailbox stop logging for 24 hours (sync events, Send, SoftDelete, and other audit actions continue unaffected; admin searches against other mailboxes are also exempt). The first 1,000 events ARE recorded - the silenced window is the follow-on 24 hours. Throttled mailboxes are flagged with IsThrottled = True on the trigger record. Microsoft says <1% of mailboxes ever throttle, so an IsThrottled = True event is itself a strong IOC.
| Artifact | Where to look | What it shows |
|---|---|---|
| Mailbox audit (M365 unified) | Search-UnifiedAuditLog -Operations MailItemsAccessed -FreeText <appid> | Every read keyed to ClientAppId, ClientInfoString, SessionId, OperationProperties.ClientIPAddress. MailAccessType = Sync = bulk folder pull (Outlook desktop / IMAP / EWS); Bind = single message access. CISA's Sparrow uses this exact query. |
| Mailbox audit | Send / SendAs / SendOnBehalf | Outbound mail by the attacker through the OAuth token. Pivot on the same SessionId as the read events. |
| Inbox rules | New-InboxRule / Set-InboxRule / UpdateInboxRules | Auto-forward, move-to-RSS-feeds, mark-as-read rules created during the attack window. Common pattern: forward-then-delete to hide replies. |
| Mail flow rules | New-TransportRule / Set-TransportRule | Tenant-wide forwarding rules created via Graph after admin-consent abuse. |
| Mailbox permissions | Add-MailboxPermission / Add-RecipientPermission | Send-As / Send-On-Behalf delegations added to give the attacker continued sending access after token revocation. |
| Search activity (E5) | SearchQueryInitiatedExchange / SearchQueryInitiatedSharePoint | Off by default - must be enabled per-mailbox via Set-Mailbox -AuditOwner @{Add="SearchQueryInitiated"}. Reveals exactly what the attacker searched for. |
Pivot pattern using SessionId once you have one suspicious event:
# Find candidate sessions tied to the malicious app id
Search-UnifiedAuditLog -StartDate 2026-04-20 -EndDate 2026-04-27 `
-Operations MailItemsAccessed -FreeText "<malicious-app-id>" `
-ResultSize 5000 |
Select-Object -ExpandProperty AuditData | ConvertFrom-Json |
Select-Object CreationTime, UserId, ClientAppId, SessionId, ClientIPAddress, MailAccessType |
Sort-Object SessionId, CreationTime
# Pull every record under one SessionId across all Exchange operations
$sid = "<session-id-from-above>"
Search-UnifiedAuditLog -StartDate 2026-04-20 -EndDate 2026-04-27 `
-RecordType ExchangeItem,ExchangeItemAggregated,ExchangeAdmin -ResultSize 5000 |
Select-Object -ExpandProperty AuditData | ConvertFrom-Json |
Where-Object { $_.SessionId -eq $sid } |
Select-Object CreationTime, UserId, Operation, ClientIPAddress |
Sort-Object CreationTime
7 Device registration (the persistence step)
Recent campaigns (UTA0355, Storm-2372) chain consent abuse with device registration to mint a Primary Refresh Token (PRT) - a token-granting token that survives password reset and outlasts most refresh-token revocations because it's tied to the registered device, not the user session.
- Audit log op
Add deviceorAdd registered owner to devicewhere the registering app id is29d9ed98-a469-4536-ade2-f981bc1d605e(Microsoft Authentication Broker) - the same client used by ROADtools / ROADtx in UTA0355 emulation. - Sign-in logs entries where
DeviceDetail.DeviceIdis freshly created (not seen in last 7-30 days) andIsCompliant = false,IsManaged = false. - Sign-in logs entries where
AuthenticationProtocol = deviceCode= device-code flow phishing. - Conditional Access bypasses where the new device satisfies "compliant device" requirement because the attacker registered it themselves and joined it.
- Audit log op
Register deviceimmediately followed by an MFA registration event (Update userwithStrongAuthenticationMethodchanges) for the same user is the strongest indicator of UTA0355's playbook.
8 Reconstructing the timeline
Pull all of the above into a single chronology keyed off the application id and the affected user principal. The complete attack chain looks like this; use CorrelationId to glue events 1-2 together and SessionId + UniqueTokenIdentifier to glue events 3-5 together:
- User clicks phishing link → first sign-in to attacker app (
oauthsentry_aad_signinwithcategory = SignInLogs, capturesSessionId) - User accepts consent prompt →
Consent to application+Add OAuth2PermissionGrant+ (if new)Add service principalinoauthsentry_o365_audit(sameCorrelationIdas the sign-in) - App receives access + refresh tokens → service principal sign-ins begin (
oauthsentry_aad_sp_signin, each with its ownuniqueTokenIdentifier) - App calls Graph → Graph activity log entries linked by
SignInActivityId= the UTI from step 3 - App reads mail →
MailItemsAccessedevents inoauthsentry_o365_auditwith the sameAppAccessContext.AADSessionIdfrom step 1 - (optional persistence)
New-InboxRule,Add registered owner to device,Add service principal credentialsevents in the same audit stream - Attacker uses refresh token from new IPs → service principal sign-ins from anomalous geography, but with the same
appId
9 SPL starter pack
Five investigator-grade SPL queries that map onto everything described in the table above. They reuse the macro stack defined in the Detections tab (oauthsentry_o365_audit, oauthsentry_aad_signin, etc.) so you only configure index/sourcetype once.
``` 1. Every consent grant or grant-creation event in the last 30 days for known-malicious app ids ```
`oauthsentry_o365_audit` earliest=-30d
| eval Operation = replace(Operation, "\.$", "")
| where Operation IN (
"Consent to application",
"Add OAuth2PermissionGrant",
"Add delegated permission grant",
"Add app role assignment to service principal",
"Add service principal",
"Add service principal credentials"
)
| spath path=ModifiedProperties{}.Name output=mp_names
| spath path=ModifiedProperties{}.NewValue output=mp_values
| eval appid = lower(ObjectId) ``` ObjectId is the AppId on consent events; M365 unified shape has no TargetId.ServicePrincipalNames ```
| eval scope_idx = mvfind(mp_names, "(?i)ConsentAction\.Permissions")
| eval scope = lower(mvindex(mp_values, scope_idx))
| eval admin_idx = mvfind(mp_names, "(?i)ConsentContext\.IsAdminConsent")
| eval is_admin = lower(mvindex(mp_values, admin_idx))
| `oauthsentry_malicious_lookup`
| where oas_category = "malicious"
| table _time Operation UserId ClientIP appid scope is_admin oas_severity oas_comment
``` 2. Service principal sign-ins from a known-bad app id, summarised ```
`oauthsentry_aad_sp_signin` earliest=-30d
| eval appid = lower(appId)
| `oauthsentry_malicious_lookup`
| where oas_category = "malicious" AND ResultType = "0"
| stats earliest(_time) as first_seen
latest(_time) as last_seen
values(ipAddress) as ips
values(location) as countries
values(resourceDisplayName) as resources
dc(uniqueTokenIdentifier) as tokens
by appid, servicePrincipalName
``` 3. Pivot from one suspicious sign-in to every Exchange action in the same session. ```
``` Replace <SID> with the SessionId from the sign-in record. ```
`oauthsentry_o365_audit` earliest=-7d
| eval session = coalesce('AppAccessContext.AADSessionId', SessionId)
| where session = "<SID>"
| eval src_ip = coalesce(ClientIPAddress, ClientIP)
| spath path=Item.Subject output=item_subject
| table _time UserId Operation src_ip item_subject ResultStatus
| sort _time
``` 4. Pivot from one suspicious sign-in to every other audited action by the same UTI. ```
``` Replace <UTI> with the UniqueTokenIdentifier from the sign-in record. UTI surfaces both ```
``` in Entra sign-in logs (uniqueTokenIdentifier) and in M365 audit data (AppAccessContext.UniqueTokenId). ```
(`oauthsentry_aad_signin` uniqueTokenIdentifier="<UTI>") OR
(`oauthsentry_o365_audit` "AppAccessContext.UniqueTokenId"="<UTI>")
| eval src_ip = coalesce(ipAddress, ClientIPAddress, ClientIP)
| eval who = coalesce(UserId, userPrincipalName, servicePrincipalName)
| table _time sourcetype Operation operationName who src_ip
| sort _time
``` 5. Newly-observed first-party app id with broad scopes (catches UTA0352-style abuse). ```
``` Visual Studio Code is a legit app, but a non-developer principal consenting it is suspicious. ```
`oauthsentry_aad_signin` appId="aebc6443-996d-45c2-90f0-388ff96faa56" earliest=-7d
| eval upn = lower(userPrincipalName)
| join type=leftanti upn [
search `oauthsentry_aad_signin` appId="aebc6443-996d-45c2-90f0-388ff96faa56"
earliest=-187d latest=-7d
| eval upn = lower(userPrincipalName)
| dedup upn
| fields upn
]
| table _time upn ipAddress location resourceDisplayName
Detections for OAuth abuse
Nine SPL detections built for teams running M365 audit and Microsoft Entra logs through Splunk. Every search uses macros so the same logic works whether you're on the Splunk Add-on for Microsoft Office 365 (sourcetypes o365:management:activity, azure:audit) or the Splunk Add-on for Microsoft Cloud Services (sourcetypes prefixed mscs:) - define the macros once and the searches travel with you. Tradecraft references in each detection point back to the Tradecraft tab.
$SPLUNK_HOME/etc/apps/<your-app>/local/macros.conf, restart, and adjust the index= and sourcetype= values to match your environment. Every detection in this tab references at least one of them, so you change five lines in one place rather than nine searches.
[oauthsentry_o365_audit] # M365 unified audit log via the Splunk Add-on for Microsoft Cloud Services # (mscs:o365:management:activity) or the older Splunk Add-on for M365 # (o365:management:activity). Adjust index= to wherever you land it. definition = (index=o365 (sourcetype=mscs:o365:management:activity OR sourcetype=o365:management:activity)) iseval = 0 [oauthsentry_aad_audit] # Microsoft Entra audit log streamed via Azure Monitor diagnostic settings. definition = (index=azure (sourcetype=mscs:azure:audit OR sourcetype=azure:audit)) iseval = 0 [oauthsentry_aad_signin] # Microsoft Entra sign-in logs (interactive, non-interactive, SP, managed identity). definition = (index=azure (sourcetype=mscs:azure:signin OR sourcetype=azure:signin)) iseval = 0 [oauthsentry_aad_sp_signin] # Service principal sign-ins only. definition = `oauthsentry_aad_signin` category=ServicePrincipalSignInLogs iseval = 0 [oauthsentry_malicious_lookup] # Decorate results with OAuthSentry malicious-feed metadata. # Assumes you have a lookup definition named oauthsentry_malicious whose lookup file is # refreshed from https://oauthsentry.github.io/feeds/all/all_malicious.csv (any cadence). definition = lookup oauthsentry_malicious appid OUTPUT category as oas_category, severity as oas_severity, comment as oas_comment iseval = 0 [oauthsentry_risky_lookup] definition = lookup oauthsentry_risky appid OUTPUT category as oas_risky_category, severity as oas_risky_severity iseval = 0 [oauthsentry_compliance_lookup] definition = lookup oauthsentry_compliance appid OUTPUT category as oas_compliance_category iseval = 0 [oauthsentry_graph_activity] # Microsoft Graph activity logs streamed via Azure Monitor diagnostic settings # (category=MicrosoftGraphActivityLogs). The macro is intentionally permissive so # it works across the common Splunk Add-on configurations that ship Graph data: # # - Splunk Add-on for Microsoft Cloud Services via Event Hub # index=o365 sourcetype=mscs:azure:eventhub category=MicrosoftGraphActivityLogs # - Older / legacy Azure add-ons # index=azure category=MicrosoftGraphActivityLogs # - Custom HTTP Event Collector pipelines # sourcetype=azure:audit:graphactivity (or whatever your team chose) # # Tighten the macro to your actual index= and sourcetype= once you know them; the # (index=o365 OR index=azure) clause is for portability and will resolve via the # index ACL automatically. definition = ((index=o365 OR index=azure) sourcetype IN (mscs:azure:eventhub, azure:audit:graphactivity, mscs:azure:graphactivity) category=MicrosoftGraphActivityLogs) iseval = 0
1 Consent or grant created for a known-malicious application id
Highest-fidelity OAuth detection: a user explicitly consented, or an admin created a grant, for an app OAuthSentry already classifies as malicious. Should fire as critical and page on-call. The unified audit log emits at least four operation names for the same underlying consent action; this search covers them all and normalises the trailing-period quirk the M365 Management Activity API sometimes adds ("Consent to application.").
`oauthsentry_o365_audit`
| eval Operation = replace(Operation, "\.$", "")
| where Operation IN (
"Consent to application",
"Add OAuth2PermissionGrant",
"Add delegated permission grant",
"Add app role assignment to service principal"
)
``` AppId is the top-level ObjectId on Consent to application events. ```
``` TargetId.ServicePrincipalNames does NOT exist in the M365 unified ```
``` shape - it's an Azure AD diagnostic-stream field. Fall back to the ```
``` AppId nested in ExtendedProperties.additionalDetails for grant ops ```
``` whose ObjectId is the grant id rather than the app id. ```
| eval appid = lower(ObjectId)
``` Pull consent context (admin vs user) and the scope payload from ```
``` ModifiedProperties. ConsentAction.Permissions is bracket-delimited, ```
``` not JSON; regex over Scope: extracts every requested permission. ```
| spath path=ModifiedProperties{}.Name output=mp_names
| spath path=ModifiedProperties{}.NewValue output=mp_values
| eval admin_idx = mvfind(mp_names, "(?i)ConsentContext\.IsAdminConsent")
| eval is_admin = if(lower(mvindex(mp_values, admin_idx)) == "true", "yes", "no")
| eval scope_idx = mvfind(mp_names, "(?i)ConsentAction\.Permissions")
| eval permissions_raw = mvindex(mp_values, scope_idx)
| rex field=permissions_raw max_match=20 "Scope:\s*(?<_scope>[^,\]]+)"
| eval consented_scopes = mvjoin(mvdedup(_scope), ", ")
``` ExtendedProperties.additionalDetails is a JSON-encoded string; ```
``` spath input=Value drills in for User-Agent + redundant AppId. ```
| spath path=ExtendedProperties{}.Name output=ep_names
| spath path=ExtendedProperties{}.Value output=ep_values
| eval ad_idx = mvfind(ep_names, "(?i)additionalDetails")
| eval ad_raw = mvindex(ep_values, ad_idx)
| spath input=ad_raw path="User-Agent" output=user_agent
| spath input=ad_raw path="AppId" output=ad_appid
| eval appid = lower(coalesce(appid, ad_appid))
| `oauthsentry_malicious_lookup`
| where oas_category = "malicious"
| stats count
earliest(_time) as first_seen
latest(_time) as last_seen
values(UserId) as users
values(ClientIP) as src_ips
values(user_agent) as user_agents
values(Operation) as operations
values(consented_scopes) as scopes
by appid, oas_severity, is_admin, oas_comment
| convert ctime(first_seen) ctime(last_seen)
| rename oas_severity as severity, oas_comment as oauthsentry_comment
2 Service principal sign-in from a known-malicious or risky app
The app already has tokens and is using them. Fires every time the app authenticates as itself, regardless of which user originally consented. For risky-bucket apps, the search adds an IP-count threshold so legitimate first-party traffic does not page on-call.
`oauthsentry_aad_sp_signin`
| eval appid = lower(appId)
| `oauthsentry_malicious_lookup`
| where oas_category IN ("malicious", "risky")
| stats count
earliest(_time) as first_seen
latest(_time) as last_seen
values(ipAddress) as src_ips
dc(ipAddress) as ip_count
values(resourceDisplayName) as resources
values(servicePrincipalName) as sp_names
by appid, oas_category, oas_severity
``` Risky apps may legitimately authenticate; require >1 IP to alert. ```
``` Malicious apps page regardless. ```
| where oas_category = "malicious" OR ip_count > 1
| convert ctime(first_seen) ctime(last_seen)
| sort - last_seen
3 Newly observed OAuth app with high-risk scopes
Catches the next malicious app before OAuthSentry has tracked it. Heuristic: a consent event with mail/files/directory scopes for an AppId that has never been seen authenticating in your tenant in the prior 90 days. The historical baseline lives in the SP sign-in log, so this search needs both oauthsentry_o365_audit and oauthsentry_aad_sp_signin to be wired up.
`oauthsentry_o365_audit` earliest=-7d
| eval Operation = replace(Operation, "\.$", "")
| where Operation = "Consent to application"
``` Extract AppId and the requested scopes ```
| spath path=ModifiedProperties{}.Name output=mp_names
| spath path=ModifiedProperties{}.NewValue output=mp_values
| eval appid = lower(ObjectId) ``` ObjectId is the AppId on consent events; M365 unified shape has no TargetId.ServicePrincipalNames ```
| eval scope_idx = mvfind(mp_names, "(?i)ConsentAction\.Permissions")
| eval scope = lower(mvindex(mp_values, scope_idx))
``` Filter on high-risk scopes only ```
| where match(scope, "(?i)mail\.read|mail\.readwrite|mail\.send|files\.(read|readwrite)\.all|sites\.read\.all|directory\.read\.all|offline_access")
| stats earliest(_time) as first_seen
values(UserId) as users
values(scope) as scopes
count
by appid
``` Anti-join against the prior 90 days of SP sign-ins. ```
``` If the AppId has signed in before, drop it; we only want first-time appearances. ```
| join type=left appid [
search `oauthsentry_aad_sp_signin` earliest=-97d latest=-7d
| eval appid = lower(appId)
| stats earliest(_time) as historical_first by appid
]
| where isnull(historical_first)
| `oauthsentry_malicious_lookup`
| convert ctime(first_seen)
| sort - first_seen
4 Admin consent for an app not on your verified-publisher allowlist
Admin consent is an enormous power transfer; combined with an unverified publisher it's one of the strongest single OAuth signals you can write. The cleanest production form uses your oauthsentry_compliance lookup as the allowlist - if an app gets admin consent and isn't in the compliance feed, it's worth a human eyeball.
`oauthsentry_o365_audit`
| eval Operation = replace(Operation, "\.$", "")
| where Operation = "Consent to application"
| spath path=ModifiedProperties{}.Name output=mp_names
| spath path=ModifiedProperties{}.NewValue output=mp_values
``` Restrict to admin-consent events ```
| eval admin_idx = mvfind(mp_names, "(?i)ConsentContext\.IsAdminConsent")
| eval is_admin = lower(mvindex(mp_values, admin_idx))
| where is_admin = "true"
``` Pull the AppId ```
| eval appid = lower(ObjectId) ``` ObjectId is the AppId on consent events; M365 unified shape has no TargetId.ServicePrincipalNames ```
``` Drop apps that are in our compliance allowlist ```
| `oauthsentry_compliance_lookup`
| where isnull(oas_compliance_category)
| eval scope_idx = mvfind(mp_names, "(?i)ConsentAction\.Permissions")
| eval scope = lower(mvindex(mp_values, scope_idx))
| stats earliest(_time) as event_time
values(UserId) as approving_admin
values(ClientIP) as src_ips
values(scope) as scopes
by appid
| convert ctime(event_time)
| sort - event_time
5 Service principal refresh-token grants from multiple countries in a short window
Refresh-token grants are non-interactive and ordinarily come from one place - automation has stable infrastructure. An SP redeeming refresh tokens from two or more countries inside an hour is the AiTM / token-replay signature, and is also what TeamFiltration looks like as it rotates AWS regions.
`oauthsentry_aad_sp_signin` incomingTokenType="refreshToken" earliest=-1h
| iplocation ipAddress
| eval appid = lower(appId)
| stats dc(Country) as country_count
values(Country) as countries
values(ipAddress) as src_ips
values(servicePrincipalName) as sp_names
count
by appid
| where country_count > 1
| `oauthsentry_malicious_lookup`
| `oauthsentry_risky_lookup`
| sort - count
6 Mailbox accessed by an OAuth app that is not on your allowlist
Last line of defence. Fires when an OAuth app reads mail and the app id is not in your oauthsentry_compliance allowlist. MailItemsAccessed stores access type and throttle status inside OperationProperties, an array of {Name, Value} objects, so we extract those by name rather than relying on auto-flattening.
`oauthsentry_o365_audit` Operation=MailItemsAccessed
| eval appid = lower(coalesce('AppAccessContext.ClientAppId', ClientAppId, AppId))
``` MailAccessType and IsThrottled live in OperationProperties[] as Name/Value pairs ```
| spath path=OperationProperties{}.Name output=op_names
| spath path=OperationProperties{}.Value output=op_values
| eval mat_idx = mvfind(op_names, "^MailAccessType$")
| eval access_type = mvindex(op_values, mat_idx)
| eval thr_idx = mvfind(op_names, "^IsThrottled$")
| eval is_throttled= mvindex(op_values, thr_idx)
| `oauthsentry_compliance_lookup`
| where isnull(oas_compliance_category)
| `oauthsentry_malicious_lookup`
| stats count
earliest(_time) as first_seen
latest(_time) as last_seen
values(UserId) as mailbox_owners
values(ClientInfoString) as client_strings
values(ClientIPAddress) as src_ips
values(access_type) as access_types
values(is_throttled) as throttled_flags
values('AppAccessContext.AADSessionId') as session_ids
dc('AppAccessContext.AADSessionId') as session_count
by appid, oas_category, oas_severity
| where count > 5
| convert ctime(first_seen) ctime(last_seen)
| sort - count
7 Pivot from a suspicious sign-in to every audited action in the same session
The investigator's bread-and-butter search. Once you have a SessionId from an Entra sign-in record, this returns every audited action authorised by that token chain - reads, sends, rule changes, deletes. AppAccessContext.AADSessionId is set on OAuth-token-driven Exchange events; bare SessionId is the fallback for Exchange events that predate the AppAccessContext schema. Save this as an investigator search and pass the session id in via a token like $session_id$.
`oauthsentry_o365_audit`
| eval session = coalesce('AppAccessContext.AADSessionId', SessionId)
| where session = "$session_id$"
| eval appid = lower(coalesce('AppAccessContext.ClientAppId', ClientAppId, AppId))
| eval src_ip = coalesce(ClientIPAddress, ClientIP)
| spath path=Item.Subject output=item_subject
| table _time UserId Operation appid src_ip item_subject ResultStatus
| sort _time
8 Device registered immediately after a new consent
Catches the UTA0355 / Storm-2372 chained pattern: consent followed by device-registration for the same user within minutes. We aggregate consent and device events per user with conditional aggregation so the timing comparison is direct - no fragile mvfind / mvcount dance.
`oauthsentry_o365_audit`
| eval Operation = replace(Operation, "\.$", "")
| where Operation IN (
"Consent to application", "Add OAuth2PermissionGrant",
"Add device", "Add registered owner to device", "Register device"
)
| eval bucket = case(
Operation IN ("Consent to application","Add OAuth2PermissionGrant"), "consent",
Operation IN ("Add device","Add registered owner to device","Register device"), "device"
)
``` Conditional aggregation: pull each user's earliest consent-time and device-time. ```
``` min(eval(...)) is the canonical pattern; min returns numeric, ignoring null branches. ```
| stats min(eval(if(bucket = "consent", _time, null()))) as t_consent
min(eval(if(bucket = "device", _time, null()))) as t_device
values(Operation) as ops
values(ClientIP) as src_ips
by UserId
| where isnotnull(t_consent) AND isnotnull(t_device) AND t_device > t_consent
| eval window_minutes = round((t_device - t_consent)/60, 1)
| where window_minutes < 60
| convert ctime(t_consent) ctime(t_device)
| table UserId t_consent t_device window_minutes ops src_ips
| sort window_minutes
9 Credential or certificate added to an OAuth service principal
Midnight Blizzard's signature post-consent move was to add their own client secret to an existing service principal so they could authenticate as the app from anywhere, even after user-token revocation. Any credential-add on an SP, especially one that recently appeared in your malicious or risky feed, is alert-worthy. The credential change shows up as a modification of KeyDescription, PasswordCredentials, or KeyCredentials inside ModifiedProperties.
`oauthsentry_o365_audit`
| eval Operation = replace(Operation, "\.$", "")
| where Operation IN (
"Add service principal credentials",
"Update application - Certificates and secrets management",
"Update service principal"
)
| spath path=ModifiedProperties{}.Name output=mp_names
| spath path=ModifiedProperties{}.NewValue output=mp_values
``` Filter to events that actually mutate credential properties. ```
``` mvfind returns null (NOT -1) when no value matches; isnotnull() is the right check. ```
| where isnotnull(mvfind(mp_names, "(?i)KeyDescription|PasswordCredentials|KeyCredentials"))
| eval appid = lower(ObjectId) ``` ObjectId is the AppId on consent events; M365 unified shape has no TargetId.ServicePrincipalNames ```
| `oauthsentry_malicious_lookup`
| `oauthsentry_risky_lookup`
| eval flag = case(
isnotnull(oas_category), "malicious",
isnotnull(oas_risky_category), "risky",
1==1, "unknown"
)
| stats earliest(_time) as first_seen
latest(_time) as last_seen
values(UserId) as initiator
values(ClientIP) as src_ips
values(Operation) as ops
by appid, flag
| convert ctime(first_seen) ctime(last_seen)
| sort - last_seen
10 FOCI-app token used to enumerate the directory via Graph
The post-token-theft recon signature. Microsoft Office, Azure CLI, Teams and the rest of the FOCI family don't normally call /users, /groups, /directoryRoles, /applications or /servicePrincipals - those endpoints are admin tools, not Office tools. A delegated Graph call from a FOCI app id to one of those resources is the textbook TeamFiltration / UTA0352 / UNK_SneakyStrike pattern: attacker steals a family refresh token, redeems it for an Office (or other FOCI) access token, then uses that token's broad pre-consented permissions to enumerate the directory. Fires regardless of responseStatusCode because the attempt is the IOC; attackers retry with corrected pagination headers on the next attempt.
This detection requires the Microsoft Graph activity log stream to be enabled (Entra admin center → Monitoring & health → Diagnostic settings → MicrosoftGraphActivityLogs) and the oauthsentry_graph_activity macro defined at the top of this tab. See the anonymized reference event in Forensic traces section 5 for the field layout.
`oauthsentry_graph_activity`
``` Field paths in mscs:azure:eventhub place every Graph attribute under ```
``` properties.*. Older add-ons or custom flatteners may also alias these to ```
``` top-level fields - keep the coalesce() if your tenant straddles add-ons. ```
| eval req_uri = 'properties.requestUri'
| eval req_method = 'properties.requestMethod'
| eval req_appid = 'properties.appId'
| eval req_spid = 'properties.servicePrincipalId' ``` populated on app-only flows ```
| eval req_userid = coalesce('properties.userId', 'properties.UserPrincipalObjectID')
| eval req_wids = 'properties.wids'
| eval req_scopes = 'properties.scopes'
| eval req_ua = 'properties.userAgent'
| eval req_ip = coalesce('properties.ipAddress', callerIpAddress)
| eval req_uti = 'properties.signInActivityId'
| eval req_status = 'properties.responseStatusCode'
``` Filter 1: token came from a FOCI app id (Secureworks canonical list + variants). ```
| where lower(req_appid) IN (
"04b07795-8ddb-461a-bbee-02f9e1bf7b46", ``` Microsoft Azure CLI ```
"1950a258-227b-4e31-a9cf-717495945fc2", ``` Microsoft Azure PowerShell ```
"1fec8e78-bce4-4aaf-ab1b-5451cc387264", ``` Microsoft Teams ```
"d3590ed6-52b3-4102-aeff-aad2292ab01c", ``` Microsoft Office ```
"ab9b8c07-8f02-4f72-87fa-80105867a763", ``` OneDrive SyncEngine ```
"27922004-5251-4030-b22d-91ecd9a37ea4", ``` Outlook Mobile ```
"4813382a-8fa7-425e-ab75-3b753aab3abb", ``` Microsoft Authenticator App ```
"872cd9fa-d31f-45e0-9eab-6e460a02d1f1", ``` Visual Studio ```
"00b41c95-dab0-4487-9791-b9d2c32c80f2", ``` Office 365 Management ```
"af124e86-4e96-495a-b70a-90f90ab96707", ``` OneDrive iOS App ```
"26a7ee05-5602-4d76-a7ba-eae8b7b67941" ``` Windows Search ```
)
``` Filter 2: directory-enumeration / app-inventory endpoints. ```
``` Boundary characters anchor on the resource name to avoid /me/users-like false matches. ```
| where match(req_uri, "(?i)/v1\.0/(users|groups|directoryRoles|servicePrincipals|applications|devices)(\?|/|$)")
``` Enrichment: pull $top= so bulk-pull intent surfaces at triage time. ```
| rex field=req_uri "(?i)\$top=(?<top_value>\d+)"
| eval top_value = if(isnotnull(top_value), tonumber(top_value), 0)
``` Enrichment: flag non-admin actors. b79fbf4d-... is the default-user wid and ```
``` appears on every non-guest account. Presence of any other GUID in wids = user ```
``` holds at least one Entra role. Non-admins enumerating the directory at scale ```
``` is the high-fidelity slice of this detection. ```
| eval wids_clean = lower(replace(req_wids, "[\"\s\[\]]", ""))
| eval is_default_user_only = if(
match(wids_clean, "^b79fbf4d-3ef9-4689-8143-76b194e85509$"),
"yes-non-admin", "no-has-role")
``` Enrichment: count scopes; > 20 on a delegated FOCI token is anomalous. ```
| eval scope_count = if(isnotnull(req_scopes),
mvcount(split(req_scopes, " ")), 0)
| stats count
earliest(_time) as first_seen
latest(_time) as last_seen
values(req_uri) as endpoints
values(req_appid) as appids
values(req_method) as methods
values(req_ip) as src_ips
values(req_ua) as user_agents
values(req_uti) as token_utis
values(req_status) as status_codes
max(top_value) as max_top_requested
max(scope_count) as max_scopes_in_token
values(is_default_user_only) as actor_role
by req_userid
| convert ctime(first_seen) ctime(last_seen)
| sort - count
Tuning recommendations specific to this detection:
- Page on-call when
actor_role = "yes-non-admin"ANDmax_top_requested >= 50. Non-admin + bulk pull is the cleanest possible signature. - Daily hunting review for everything else - the detection will catch some legitimate edge cases (e.g. an admin debugging a Graph integration via Azure CLI) where context determines verdict.
- Suppress via a per-tenant lookup of trusted automation accounts that have a documented business reason to do directory enumeration via FOCI clients (rare but not zero).
11 Cumulative directory-pull volume from one token over time
Detection 10 fires on the shape of a single request (FOCI app + directory endpoint). This detection fires on the cumulative volume of records one token tried to retrieve, regardless of whether each individual request succeeded. It catches three patterns Detection 10 misses:
- Iterative probing - attacker tries
$top=2000(400), retries with$top=999+ConsistencyLevel: eventual(200), then paginates. Detection 10 fires once on the first request; Detection 11 sees the full ~10k records they actually walked. - Slow-and-low - attacker pulls 100 records every 5 minutes for 8 hours (~9,600 records, distributed). No single request looks alarming; the cumulative shape is the IOC.
- Pagination without
$top- omitting$topdefaults to 100 records per page; attacker just walks@odata.nextLink. Detection 10'smax_top_requestedstays at 0; Detection 11 counts each request as 100.
The arithmetic: for each Graph request to a directory endpoint, attribute "records attempted" - if $top is present, that's the value; otherwise default to 100 (Graph's implicit page size). 200/206 responses contribute the full $top; 400/429 also contribute the full $top because the intent was that volume even if the server rejected it.
`oauthsentry_graph_activity` earliest=-1h
| eval req_uri = 'properties.requestUri'
| eval req_method = 'properties.requestMethod'
| eval req_appid = 'properties.appId'
| eval req_spid = 'properties.servicePrincipalId'
| eval req_userid = coalesce('properties.userId', 'properties.UserPrincipalObjectID', req_spid)
| eval req_uti = 'properties.signInActivityId'
| eval req_ip = coalesce('properties.ipAddress', callerIpAddress)
| eval req_status = 'properties.responseStatusCode'
| eval req_size = 'properties.responseSizeBytes'
``` Restrict to directory-enumeration endpoints. /me/* is excluded - those are ```
``` self-service reads against the user's own data and not bulk-enum candidates. ```
| where match(req_uri, "(?i)/v1\.0/(users|groups|directoryRoles|servicePrincipals|applications|devices)(\?|/|$)")
| where req_method = "GET"
``` Pull $top= if present; default to 100 (Graph's implicit page size when ```
``` $top is omitted) so pagination-only walks still count toward the total. ```
| rex field=req_uri "(?i)\$top=(?<top_raw>\d+)"
| eval records_attempted = coalesce(tonumber(top_raw), 100)
``` Aggregate per-token. The token UTI (signInActivityId) is the right key - ```
``` it pinpoints one specific access token, so two tokens for the same user ```
``` from two different sessions are scored independently. For app-only flows ```
``` userId is null - we fall through to servicePrincipalId via coalesce above. ```
| stats sum(records_attempted) as total_records_attempted
sum(req_size) as total_response_bytes
count as request_count
dc(req_uri) as distinct_endpoints
values(req_uri) as endpoints
values(req_appid) as appids
values(req_ip) as src_ips
values(req_status) as status_codes
max(coalesce(tonumber(top_raw),0)) as max_top
earliest(_time) as first_seen
latest(_time) as last_seen
by req_userid, req_uti
``` Three-tier severity: ```
``` critical: >= 10000 records attempted in 1h (clear bulk enum intent) ```
``` high: >= 1000 records attempted in 1h (worth daily review) ```
``` info: >= 250 (drops out of this query, picked up by 24h variant) ```
| eval severity = case(
total_records_attempted >= 10000, "critical",
total_records_attempted >= 1000, "high",
1==1, null
)
| where isnotnull(severity)
| convert ctime(first_seen) ctime(last_seen)
| sort - total_records_attempted
Slow-and-low variant for the 24-hour window with a lower per-token threshold:
`oauthsentry_graph_activity` earliest=-24h
| eval req_uri = 'properties.requestUri'
| eval req_method = 'properties.requestMethod'
| eval req_appid = 'properties.appId'
| eval req_spid = 'properties.servicePrincipalId'
| eval req_userid = coalesce('properties.userId', 'properties.UserPrincipalObjectID', req_spid)
| eval req_uti = 'properties.signInActivityId'
| where match(req_uri, "(?i)/v1\.0/(users|groups|directoryRoles|servicePrincipals|applications|devices)(\?|/|$)")
| where req_method = "GET"
| rex field=req_uri "(?i)\$top=(?<top_raw>\d+)"
| eval records_attempted = coalesce(tonumber(top_raw), 100)
``` Bucket into 1h windows so we can see the rate, not just the daily total ```
| bin _time span=1h
| stats sum(records_attempted) as records_per_hour count as requests_per_hour by _time, req_userid, req_uti, req_appid
``` Aggregate the 24 hourly buckets per token ```
| stats sum(records_per_hour) as total_records_24h
sum(requests_per_hour) as total_requests_24h
max(records_per_hour) as peak_hour
dc(_time) as active_hours
values(req_appid) as appids
earliest(_time) as first_bucket
latest(_time) as last_bucket
by req_userid, req_uti
``` Threshold tuned for distributed enumeration: 250+ records sustained over ```
``` 4+ active hours, OR 5000+ total records regardless of distribution. ```
| where (total_records_24h >= 250 AND active_hours >= 4)
OR (total_records_24h >= 5000)
| convert ctime(first_bucket) ctime(last_bucket)
| sort - total_records_24h
Tuning recommendations specific to this detection:
- Critical threshold (10k records / 1h) is the alert tier. Pages on-call. Almost no legitimate workflow pulls 10,000 directory records from a delegated user token in one hour - this is automation territory and automation should be using app-only tokens with documented service principals (which would be filtered out by the user-token restriction implicit in this query).
- High threshold (1k records / 1h) is daily review. Catches admins doing genuine work via Azure CLI / Graph Explorer / a custom script - those will appear here legitimately. Tune by adding a per-tenant allowlist of admin object IDs that are expected to read the directory at this rate.
- Slow-and-low (250 over 4 hours, or 5k over 24h) is daily review. The 4-hour active-hour requirement filters out legitimate one-off bulk pulls (which complete in minutes); a token that reads 250+ records every hour for 4+ hours is automated and not interactive.
- The pagination-without-
$topcase is the most subtle. Default page size assumption (100) may overcount if your tenant routinely uses smaller pages, or undercount on/users/$select=...queries where the actual returned size depends on the response shape. Validate by samplingresponseSizeBytesin your tenant: if average response size on no-$toprequests is ≪ 100 records, lower the default. - Anti-correlate with Detection 10. A token that fires both Detection 10 (FOCI app + directory endpoint) and Detection 11 (cumulative volume) is high-confidence. Detection 11 alone, from a non-FOCI app id, is genuine admin work or third-party integration - lower confidence by itself.
- Status-code distribution as a tell. Add
list(req_status) as status_seqto either query and look for the400 → 200, 200, 200, ...pattern (failed probe followed by successful pagination). That sequence is the smoking gun for iterative probing and is essentially never seen in legitimate use.
Tuning notes
- The macros above reference three lookup definitions:
oauthsentry_malicious,oauthsentry_risky,oauthsentry_compliance. Each one points at a CSV with at minimum anappidfield; populate the lookup files from the OAuthSentry CSV feeds at/feeds/all/{malicious,risky,compliance}.csvon whatever cadence makes sense for your tenant. Splunk's built-in Lookup Editor app or a scheduled| outputlookupfrom a REST search are both fine. - Detection 3 (newly observed OAuth app) is the noisiest but the most valuable. Tune by adding an extra anti-join against
oauthsentry_complianceif you want to suppress already-vetted apps automatically. - Detections 1, 2, 8, 9, 10, 11 are high-fidelity enough to alert on (Detection 10 specifically when the actor is a non-admin and
$topis >= 50; Detection 11 at the critical 10,000-record/1h tier). Detections 3, 4, 5, 6 work better as scheduled hunts with daily review. Detection 7 is investigator-driven, not an alert. - Apps in the risky bucket are legitimate. Trigger on context (unusual user, new IP, new geography) rather than on the app id alone - detections 2 and 5 demonstrate the pattern.
- Where the unified audit log surfaces both
SessionIddirectly andAppAccessContext.AADSessionId, prefer the latter - it is consistently populated for OAuth-token-driven activity, while bareSessionIdis only present on Exchange events. - The M365 Management Activity API sometimes appends a trailing period to operation names (
"Consent to application."); Azure Monitor's diagnostic stream does not. Every search above normalises witheval Operation = replace(Operation, "\.$", "")so the same SPL works across both pipelines. - If a search returns nothing where you expected results,
tail 1a raw event from the relevant sourcetype and confirm the field paths match - some Splunk add-ons CIM-mapappId,ipAddressand friends to flat lowercase aliases (app,src) that override the JSON-extracted versions.
Integrating with your existing detection stack
The nine detections above assume you're starting from scratch. If you already run an Entra/M365 detection stack - and most teams do - OAuthSentry's three feeds plug into rules you already have. Four patterns; each one is a small surgical change that takes a hand-maintained list off your plate or sharpens an alert tier you've already tuned.
Pattern 1: replace a hand-maintained "suspicious app id" lookup
If you have a rule like "alert when a user signs in from an app id on our internal suspicious-apps lookup" or "alert on consent to an app on our blocklist", swap the lookup for the OAuthSentry malicious feed. Same logic, daily-refreshed list, community-curated rather than a spreadsheet someone updates when they remember to.
``` Before: hand-maintained lookup ```
... | lookup my_suspicious_apps appid OUTPUT severity comment
| where isnotnull(severity)
``` After: OAuthSentry feeds, with risky as a second tier ```
... | `oauthsentry_malicious_lookup`
| `oauthsentry_risky_lookup`
| where oas_category="malicious" OR isnotnull(oas_risky_category)
| eval severity = coalesce(oas_severity, oas_risky_severity)
| eval bucket = coalesce(oas_category, oas_risky_category)
Pattern 2: enrich an existing alert with severity / category
For rules that already fire on a real signal (mailbox accessed by an SP with a suspicious user agent, email deleted by an SP, service-account token sign-in, etc.), don't change the trigger - stamp the OAuthSentry verdict onto the alert so triage starts with context instead of a raw GUID. The analyst opens the ticket and sees appid = AzureHound (risky-bucket) instead of "appid = some-guid, go look it up".
``` Add to the tail of any rule whose alert object contains an appid field. ```
``` Doesn't change firing logic, just stamps a verdict on every alert. ```
| `oauthsentry_malicious_lookup`
| `oauthsentry_risky_lookup`
| `oauthsentry_compliance_lookup`
| eval oas_verdict = case(
oas_category = "malicious", "MALICIOUS - " . oas_comment,
isnotnull(oas_risky_category), "RISKY - " . oas_risky_category,
isnotnull(oas_compliance_category), "COMPLIANCE - ". oas_compliance_category,
true(), "UNKNOWN")
| eval alert_priority = case(
oas_category="malicious", "P1",
isnotnull(oas_risky_category), "P2",
isnotnull(oas_compliance_category), "P4",
true(), "P3")
Pattern 3: suppress noise on "rare application consent" with the compliance feed
The classic baseline detection - "alert when we see a new app id we haven't observed in 90 days" - is the highest-value hunt and the noisiest detection. Joining against the compliance feed as an anti-filter drops legitimate first-party and well-known third-party apps automatically; what remains is the actual signal.
``` Your existing "rare application consent" rule, whatever it looks like ```
your_existing_rare_consent_search
``` Drop apps that are on the compliance feed - first-party + well-known SaaS ```
| `oauthsentry_compliance_lookup`
| where isnull(oas_compliance_category)
``` Optionally: positively flag the malicious / risky tier so the alert lands ```
``` already-tiered rather than as a flat "rare app" stream ```
| `oauthsentry_malicious_lookup`
| `oauthsentry_risky_lookup`
| eval tier = case(
oas_category="malicious", "confirmed-malicious",
isnotnull(oas_risky_category), "legit-but-abusable",
true(), "truly-unknown")
Pattern 4: correlate OAuth events with downstream attacker behavior
Attackers who compromise an account via OAuth consent typically pivot within 24-72 hours: enumerate the directory, create inbox forwarding rules, delete mail traces, exfil files. If you have rules for those downstream behaviors, a backward-look subsearch into recent consent events lets you escalate severity when both halves of the chain are present in the same window.
``` Your existing rule for downstream behavior - inbox rule creation, ```
``` directory enumeration, bulk file access, anything attacker-typical. ```
your_existing_downstream_search
| eval upn = lower(userPrincipalName)
``` Look back 72h: did this same user consent to a non-compliance app? ```
| join type=left upn [
search `oauthsentry_o365_audit` Operation="Consent to application" earliest=-72h
| spath path=ModifiedProperties{}.NewValue output=mp_values
| rex field=mp_values "(?<recent_appid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"
| eval upn = lower(UserId)
| `oauthsentry_compliance_lookup`
| where isnull(oas_compliance_category)
| stats values(recent_appid) as recent_consent_appids by upn
]
``` If both halves chained, severity escalates ```
| eval severity = if(isnotnull(recent_consent_appids), "P1-chained", "P3-standalone")
Hunting playbooks
Detections fire on known-bad. Hunts find unknown-bad. These are scheduled queries that surface anomalies for triage rather than auto-page. Each is built around a baseline of "what is normal in this tenant" - run them weekly, review the deltas, promote anything actionable into the Detections tab.
1 Newly observed application IDs
The single highest-value hunt. Any app id seen authenticating in your tenant for the first time in 90 days deserves a look, regardless of whether it's a malicious app, a risky-bucket app, or a legitimate one being adopted by a department.
``` Run weekly. Lookback: last 7 days vs prior 90. ```
`oauthsentry_aad_sp_signin` resultType="0" earliest=-7d
| eval appid = lower(appId)
| stats earliest(_time) as first_seen
dc(uniqueTokenIdentifier) as token_count
values(ipAddress) as ips
values(location) as countries
values(resourceDisplayName) as resources
values(servicePrincipalName) as sp_name
values(appOwnerTenantId) as owner_tenants
by appid
``` Drop apps that have signed in in the prior 90 days. ```
| search NOT [
search `oauthsentry_aad_sp_signin` resultType="0" earliest=-97d latest=-7d
| eval appid = lower(appId)
| dedup appid
| fields appid
]
``` Tag Microsoft first-party tenants so triage can focus on third-party apps. ```
| eval owner_known = if(isnotnull(mvfind(owner_tenants,
"f8cdef31-a31e-4b4a-93e4-5f571e91255a|72f988bf-86f1-41af-91ab-2d7cd011db47")),
"microsoft", "third-party")
| convert ctime(first_seen)
| sort first_seen
2 FOCI app spike from anomalous geography
TeamFiltration / UNK_SneakyStrike abuses family refresh tokens by signing in to multiple FOCI apps in quick succession from rotating AWS regions. The signature is a non-interactive sign-in surge against several of the family client IDs from a country the user has never signed in from before. (Note: Microsoft Authentication Broker 29d9ed98-a469-4536-ade2-f981bc1d605e is not on Secureworks' canonical FOCI list - it's a separate device-code phishing vector. The companion query at the bottom hunts that path.)
`oauthsentry_aad_signin` category=NonInteractiveUserSignInLogs resultType="0" earliest=-7d
appId IN (
"04b07795-8ddb-461a-bbee-02f9e1bf7b46", ``` Azure CLI ```
"1950a258-227b-4e31-a9cf-717495945fc2", ``` Azure PowerShell ```
"1fec8e78-bce4-4aaf-ab1b-5451cc387264", ``` Microsoft Teams ```
"d3590ed6-52b3-4102-aeff-aad2292ab01c", ``` Microsoft Office ```
"ab9b8c07-8f02-4f72-87fa-80105867a763", ``` OneDrive SyncEngine ```
"27922004-5251-4030-b22d-91ecd9a37ea4", ``` Outlook Mobile ```
"4813382a-8fa7-425e-ab75-3b753aab3abb", ``` Authenticator App ```
"872cd9fa-d31f-45e0-9eab-6e460a02d1f1", ``` Visual Studio ```
"00b41c95-dab0-4487-9791-b9d2c32c80f2", ``` Office 365 Management ```
"af124e86-4e96-495a-b70a-90f90ab96707", ``` OneDrive iOS App ```
"26a7ee05-5602-4d76-a7ba-eae8b7b67941" ``` Windows Search ```
)
| eval upn = lower(userPrincipalName)
| eval country = location
``` Anti-join against the prior 180 days of (user, country) pairs. ```
| join type=leftanti upn, country [
search `oauthsentry_aad_signin` category=NonInteractiveUserSignInLogs resultType="0"
earliest=-187d latest=-7d
| eval upn = lower(userPrincipalName)
| eval country = location
| dedup upn, country
| fields upn, country
]
``` Group by user-and-hour, fire when 3+ FOCI apps appear in the same hour. ```
| bin _time span=1h
| stats dc(appId) as foci_app_count
values(appDisplayName) as apps
values(ipAddress) as ips
any(country) as country
count as sign_ins
by upn, _time
| where foci_app_count >= 3
| convert ctime(_time)
| sort - sign_ins
``` Companion hunt: device-code phishing via Microsoft Authentication Broker (UTA0355 / Storm-2372). ```
``` MAB is not FOCI, but it's the standard PRT-issuance client and the most commonly phished one. ```
`oauthsentry_aad_signin` appId="29d9ed98-a469-4536-ade2-f981bc1d605e"
authenticationProtocol=deviceCode resultType="0" earliest=-7d
| table _time userPrincipalName ipAddress location resourceDisplayName 'deviceDetail.deviceId'
| sort _time
3 Service principals signing in from a single IP only
Service principals are usually accessed by automation that has stable infrastructure: an SP that has only ever been seen from one IP in the last 30 days is unusual. Combined with low call volume, it's a candidate for an attacker-mounted credential.
`oauthsentry_aad_sp_signin` resultType="0" earliest=-30d
| eval appid = lower(appId)
``` eventstats annotates each event with the dc(ipAddress) for its appid; we then keep only events for apps with ip_count = 1. ```
| eventstats dc(ipAddress) as ip_count by appid
| where ip_count = 1
| stats count as sign_ins
values(ipAddress) as src_ip
values(servicePrincipalName) as sp_name
earliest(_time) as first_seen
latest(_time) as last_seen
by appid
| where sign_ins < 50 ``` tune to your tenant volume ```
| convert ctime(first_seen) ctime(last_seen)
| sort first_seen
4 Federated identity credential added to an application
Federated credentials are subtle persistence: there is no client secret to spot in a credential audit, the credential record is small, and to the audit log it looks like a routine application update. Run weekly across the whole tenant; the volume should be near zero in most environments. Field paths assume Azure Monitor diagnostic stream layout (properties.targetResources[].modifiedProperties[]); see the tuning note below if your TA flattens them differently.
`oauthsentry_aad_audit` earliest=-7d
| eval Operation = replace(coalesce(operationName, Operation), "\.$", "")
| where Operation IN ("Add federated identity credential",
"Update federated identity credential")
``` Pull modifiedProperties out of properties.targetResources[0].modifiedProperties[] ```
| spath path="properties.targetResources{}.modifiedProperties{}.displayName" output=mp_names
| spath path="properties.targetResources{}.modifiedProperties{}.newValue" output=mp_values
| spath path="properties.targetResources{}.displayName" output=app_names
``` Initiator can be a user or another app (e.g. an automation pipeline) ```
| spath path="properties.initiatedBy.user.userPrincipalName" output=user_init
| spath path="properties.initiatedBy.app.displayName" output=app_init
| eval initiator = coalesce(user_init, app_init)
| eval app_name = mvindex(app_names, 0)
| eval issuer_idx = mvfind(mp_names, "(?i)^issuer$")
| eval issuer = mvindex(mp_values, issuer_idx)
| eval subject_idx = mvfind(mp_names, "(?i)^subject$")
| eval subject = mvindex(mp_values, subject_idx)
| table _time Operation initiator app_name issuer subject correlationId
| sort - _time
5 Actor Token Forgery retrospective hunt (CVE-2025-55241)
Patched September 2025, but pre-patch abuse leaves few traces. The cleanest residual signal: an audit-log action where the initiatedBy.user.displayName matches a Microsoft service name (Office 365 Exchange Online, Microsoft Graph, etc.) but the userPrincipalName is a real human's UPN. Microsoft services don't sign in as humans.
``` Go back as far as your retention allows. ```
`oauthsentry_aad_audit` earliest=-180d
| spath path="properties.initiatedBy.user.userPrincipalName" output=upn_raw
| spath path="properties.initiatedBy.user.displayName" output=svc_name
| where isnotnull(upn_raw)
| eval upn = lower(upn_raw)
``` The mismatch is the smoking gun: service-name initiator + real-looking UPN. ```
| where svc_name IN (
"Office 365 Exchange Online", "Microsoft Graph",
"Office 365 SharePoint Online", "Skype for Business Online",
"Microsoft Teams Services", "Microsoft Intune",
"Azure Multi-Factor Auth Client", "Microsoft Office 365 Portal"
)
| where match(upn, "^[^@]+@[^@]+\.[a-z]+$")
``` Drop the legitimate combo of MS-tenant onmicrosoft.com UPNs running MS service ops. ```
| where NOT (match(upn, "@onmicrosoft\.com$") AND match(svc_name, "(?i)^Microsoft"))
| eval Operation = replace(coalesce(operationName, Operation), "\.$", "")
| table _time Operation svc_name upn correlationId
| sort _time
6 Service principal credential added without a corresponding privileged role
Adding a credential to a service principal is sometimes legitimate (admin rotating a secret). It is suspicious when the initiator does not currently hold a role that would justify it. Joins audit logs against role-membership audits.
`oauthsentry_aad_audit` earliest=-30d
| eval Operation = replace(coalesce(operationName, Operation), "\.$", "")
| where Operation IN ("Add service principal credentials",
"Update application - Certificates and secrets management")
| spath path="properties.targetResources{}.id" output=tgt_ids
| spath path="properties.targetResources{}.displayName" output=tgt_names
| spath path="properties.initiatedBy.user.id" output=init_id
| spath path="properties.initiatedBy.user.userPrincipalName" output=init_upn
| eval sp_id = mvindex(tgt_ids, 0)
| eval sp_name = mvindex(tgt_names, 0)
``` Anti-join: drop initiators who hold a privileged role assignment. ```
| join type=leftanti init_id [
search `oauthsentry_aad_audit` earliest=-365d
| eval Operation = replace(coalesce(operationName, Operation), "\.$", "")
| where Operation = "Add member to role"
| spath path="properties.targetResources{}.modifiedProperties{}.newValue" output=role_changes
| where match(mvjoin(role_changes, " "),
"(?i)Application Administrator|Cloud Application Administrator|Global Administrator|Privileged Role Administrator")
| spath path="properties.targetResources{}.id" output=role_target_id
| eval init_id = mvindex(role_target_id, 0)
| dedup init_id
| fields init_id
]
| table _time sp_name sp_id init_upn correlationId
| sort - _time
7 CAE-aware token issuance to anomalous (user, IP) pairs
From AADInternals research. CAE tokens last ~24-28h instead of ~1h - normally a security feature, but attackers prefer them when available because they survive longer between forced re-authentications. Hunt for sign-ins issuing CAE-aware tokens to (user, IP) combinations not seen in the prior 180 days.
`oauthsentry_aad_signin` (category=SignInLogs OR category=NonInteractiveUserSignInLogs) earliest=-7d
``` AuthenticationProcessingDetails is an array of {key, value} pairs; pull "Is CAE Token". ```
| spath path="properties.authenticationProcessingDetails{}.key" output=apd_keys
| spath path="properties.authenticationProcessingDetails{}.value" output=apd_values
| eval cae_idx = mvfind(apd_keys, "(?i)Is CAE Token")
| where isnotnull(cae_idx)
| eval is_cae = lower(mvindex(apd_values, cae_idx))
| where is_cae = "true"
| eval upn = lower(userPrincipalName)
``` Drop (user, IP) pairs that are already familiar in the last 180 days. ```
| join type=leftanti upn, ipAddress [
search `oauthsentry_aad_signin` category=SignInLogs earliest=-187d latest=-7d
| eval upn = lower(userPrincipalName)
| dedup upn, ipAddress
| fields upn, ipAddress
]
| table _time upn appId appDisplayName resourceIdentity ipAddress location uniqueTokenIdentifier
| sort _time
8 Non-developer principals using developer-tool app IDs
A senior accountant using Visual Studio Code? Possible. A senior accountant using Azure CLI for the first time ever? Almost certainly not. The ConsentFix campaign and UTA0352 both exploit pre-consented developer-tool apps that ordinary users have no business touching.
`oauthsentry_aad_signin` (category=SignInLogs OR category=NonInteractiveUserSignInLogs)
resultType="0" earliest=-14d
appId IN (
"04b07795-8ddb-461a-bbee-02f9e1bf7b46", ``` Azure CLI ```
"1950a258-227b-4e31-a9cf-717495945fc2", ``` Azure PowerShell ```
"aebc6443-996d-45c2-90f0-388ff96faa56", ``` Visual Studio Code ```
"04f0c124-f2bc-4f59-8241-bf6df9866bbd", ``` Visual Studio ```
"872cd9fa-d31f-45e0-9eab-6e460a02d1f1" ``` Visual Studio (older client) ```
)
| eval upn = lower(userPrincipalName)
``` Drop users who already use any of these apps in the prior 180 days. ```
| join type=leftanti upn [
search `oauthsentry_aad_signin` (category=SignInLogs OR category=NonInteractiveUserSignInLogs)
resultType="0" earliest=-194d latest=-14d
appId IN (
"04b07795-8ddb-461a-bbee-02f9e1bf7b46",
"1950a258-227b-4e31-a9cf-717495945fc2",
"aebc6443-996d-45c2-90f0-388ff96faa56",
"04f0c124-f2bc-4f59-8241-bf6df9866bbd",
"872cd9fa-d31f-45e0-9eab-6e460a02d1f1"
)
| eval upn = lower(userPrincipalName)
| dedup upn
| fields upn
]
| table _time upn appDisplayName appId ipAddress location userAgent authenticationProtocol
| sort _time
9 FOCI app token used outside the app's normal Graph traffic shape
Each FOCI app has a stable set of resources it normally talks to: Microsoft Office hits /me/messages, /me/drive, /me/calendar, /me/joinedTeams; Teams hits /chats, /teams; OneDrive hits /me/drive; Authenticator hits /me and the device endpoints. Calls from any FOCI app to /users, /groups, /directoryRoles, /applications, /servicePrincipals or /devices are post-token-theft directory recon by definition. This hunt builds a 90-day baseline of the (FOCI app, resource family) pairs your tenant actually sees, then surfaces any pair that breaks out of it.
`oauthsentry_graph_activity` earliest=-7d
| eval req_uri = 'properties.requestUri'
| eval req_appid = 'properties.appId'
| eval req_spid = 'properties.servicePrincipalId'
| eval req_userid = coalesce('properties.userId', 'properties.UserPrincipalObjectID', req_spid)
| eval req_ip = coalesce('properties.ipAddress', callerIpAddress)
``` Restrict to FOCI apps ```
| where lower(req_appid) IN (
"04b07795-8ddb-461a-bbee-02f9e1bf7b46","1950a258-227b-4e31-a9cf-717495945fc2",
"1fec8e78-bce4-4aaf-ab1b-5451cc387264","d3590ed6-52b3-4102-aeff-aad2292ab01c",
"ab9b8c07-8f02-4f72-87fa-80105867a763","27922004-5251-4030-b22d-91ecd9a37ea4",
"4813382a-8fa7-425e-ab75-3b753aab3abb","872cd9fa-d31f-45e0-9eab-6e460a02d1f1",
"00b41c95-dab0-4487-9791-b9d2c32c80f2","af124e86-4e96-495a-b70a-90f90ab96707",
"26a7ee05-5602-4d76-a7ba-eae8b7b67941"
)
``` Normalise the endpoint to its resource family - the first segment after /v1.0/ ```
| rex field=req_uri "(?i)/v1\.0/(?<resource>[a-zA-Z]+)"
| eval resource = lower(resource)
``` Only the resources that have no business being touched by FOCI clients ```
| where resource IN ("users","groups","directoryroles","applications","serviceprincipals","devices")
``` Anti-join: drop (appid, resource) pairs that are commonplace in YOUR tenant ```
``` over the prior 90 days. Tune by lowering the count threshold below if your ```
``` tenant has very low Graph volume; remove the join entirely to see raw hits. ```
| join type=leftanti req_appid, resource [
search `oauthsentry_graph_activity` earliest=-97d latest=-7d
| eval req_uri = 'properties.requestUri'
| eval req_appid = 'properties.appId'
| rex field=req_uri "(?i)/v1\.0/(?<resource>[a-zA-Z]+)"
| eval resource = lower(resource)
| stats count by req_appid, resource
| where count > 100
| fields req_appid, resource
]
| stats count
values(resource) as resources_hit
values(req_uri) as endpoints
values(req_userid) as users
values(req_ip) as src_ips
earliest(_time) as first_seen
latest(_time) as last_seen
by req_appid
| convert ctime(first_seen) ctime(last_seen)
| sort - count
10 Anomalous user-agents on Graph calls from FOCI apps
Real Microsoft Office, Teams, OneDrive and Authenticator clients set well-known UA strings that include the OS, the client version and the Microsoft client identifier. Graph calls from FOCI app ids carrying any of: Trident (Internet Explorer 11, end-of-life since 2022), python-requests, Go-http-client, Java/, curl/, ROADtools / MSAL-Python / aiohttp / OkHttp identifiers, or doubly-prefixed User-Agent: User-Agent: ... headers (a tooling artifact that surfaces in some attacker frameworks) are by-definition anomalous. The signal is fuzzy on its own - one or two false positives are normal - but the combination of FOCI app + tooling-style UA + non-routine resource (anti-correlate with Hunt 9) is high-confidence.
`oauthsentry_graph_activity` earliest=-7d
| eval req_uri = 'properties.requestUri'
| eval req_appid = 'properties.appId'
| eval req_ua = 'properties.userAgent'
| eval req_spid = 'properties.servicePrincipalId'
| eval req_userid = coalesce('properties.userId', 'properties.UserPrincipalObjectID', req_spid)
| eval req_ip = coalesce('properties.ipAddress', callerIpAddress)
| eval req_uti = 'properties.signInActivityId'
| where lower(req_appid) IN (
"04b07795-8ddb-461a-bbee-02f9e1bf7b46","1950a258-227b-4e31-a9cf-717495945fc2",
"1fec8e78-bce4-4aaf-ab1b-5451cc387264","d3590ed6-52b3-4102-aeff-aad2292ab01c",
"ab9b8c07-8f02-4f72-87fa-80105867a763","27922004-5251-4030-b22d-91ecd9a37ea4",
"4813382a-8fa7-425e-ab75-3b753aab3abb","872cd9fa-d31f-45e0-9eab-6e460a02d1f1",
"00b41c95-dab0-4487-9791-b9d2c32c80f2","af124e86-4e96-495a-b70a-90f90ab96707",
"26a7ee05-5602-4d76-a7ba-eae8b7b67941"
)
``` Classify the UA. case() returns the first matching tag; ok = ignore. ```
| eval ua_flag = case(
match(req_ua, "(?i)^User-Agent:\s*User-Agent:"), "double-prefixed",
match(req_ua, "(?i)Trident/"), "ie11-trident",
match(req_ua, "(?i)python-requests"), "python-requests",
match(req_ua, "(?i)Go-http-client"), "go-http-client",
match(req_ua, "(?i)Java/"), "java",
match(req_ua, "(?i)\bcurl/"), "curl",
match(req_ua, "(?i)ROADtools|MSAL.+Python|aiohttp|okhttp"), "known-recon-tooling",
1==1, "ok"
)
| where ua_flag != "ok"
| stats count
values(req_ua) as user_agents
values(req_appid) as appids
values(req_uri) as endpoints
values(req_ip) as src_ips
values(req_uti) as token_utis
earliest(_time) as first_seen
latest(_time) as last_seen
by req_userid, ua_flag
| convert ctime(first_seen) ctime(last_seen)
| sort - count
properties. (e.g. properties.targetResources{}.modifiedProperties{}). Hunts 9 and 10 do the same but for the Graph activity log stream and reference the nested fields directly (properties.requestUri, properties.appId, properties.userAgent, etc.) - this matches the mscs:azure:eventhub sourcetype shape verified against a real tenant fieldsummary, where 100% of Graph activity attributes live under properties.*. If your add-on flattens these to top-level fields, replace each 'properties.requestUri' with requestUri directly. Hunts 1, 2, 3, 8 use top-level Azure sign-in fields (appId, userPrincipalName, ipAddress) which are stable across both the Splunk Add-on for Microsoft Cloud Services and the older Azure add-ons.
Hardening - preventing the next attack
The detections and hunts in the preceding tabs find attacks in progress. These controls reduce the attack surface so they happen less often. Roughly ordered by impact-per-effort: do the first three this quarter; the rest are months-of-work, multi-team programmes.
1 Restrict end-user OAuth consent
Default Entra setting until 2024 was that any user could consent to any app for any scope. Change this so users can only consent to verified-publisher apps requesting low-risk scopes; everything else routes through admin-consent workflow. Single highest-impact OAuth control.
Connect-MgGraph -Scopes "Policy.ReadWrite.Authorization"
# Allow users to consent only to apps from verified publishers, low-risk scopes.
# Use the canonical -BodyParameter pattern with camelCase field names.
$params = @{
defaultUserRolePermissions = @{
permissionGrantPoliciesAssigned = @(
"managePermissionGrantsForSelf.microsoft-user-default-low"
)
}
}
Update-MgPolicyAuthorizationPolicy -BodyParameter $params
# Or block end-user consent entirely (safest, but creates admin-consent backlog):
# $params = @{
# defaultUserRolePermissions = @{
# permissionGrantPoliciesAssigned = @()
# }
# }
# Update-MgPolicyAuthorizationPolicy -BodyParameter $params
# Enable the admin-consent request workflow so users can ask. Note: this cmdlet
# expects the $reviewerSettings list of users/groups/roles - replace the placeholder.
Update-MgPolicyAdminConsentRequestPolicy `
-IsEnabled:$true `
-NotifyReviewers:$true `
-RemindersEnabled:$true `
-RequestDurationInDays 30 `
-Reviewers @(
@{
Query = "/users/<reviewer-upn>"
QueryType = "MicrosoftGraph"
QueryRoot = $null
}
)
2 Apply app instance property lock to every app you own
Enabled by default since March 2024 for new apps, but legacy apps registered before then need backfill. The lock blocks adding credentials to the service principal in any tenant once the app is provisioned, defeating the SP credential backdoor technique used in Solorigate / Midnight Blizzard / "Misowned and Dangerous".
Connect-MgGraph -Scopes "Application.ReadWrite.All"
# All multi-tenant apps registered before March 2024 with no lock configured
$apps = Get-MgApplication -All |
Where-Object {
$_.SignInAudience -ne "AzureADMyOrg" -and
-not $_.ServicePrincipalLockConfiguration.IsEnabled
}
# Canonical Microsoft pattern: -BodyParameter with camelCase property names.
# isEnabled + allProperties = true is the maximum-restriction shorthand;
# it implicitly locks credentials and tokenEncryptionKeyId without naming them.
foreach ($app in $apps) {
Write-Host "Locking $($app.DisplayName)"
$params = @{
servicePrincipalLockConfiguration = @{
isEnabled = $true
allProperties = $true
}
}
Update-MgApplication -ApplicationId $app.Id -BodyParameter $params
}
# If you need fine-grained locking (e.g. to allow tokenEncryptionKeyId changes
# but block credential additions), name each property explicitly instead:
# servicePrincipalLockConfiguration = @{
# isEnabled = $true
# credentialsWithUsageVerify = $true
# credentialsWithUsageSign = $true
# tokenEncryptionKeyId = $false
# }
3 Block device-code flow in Conditional Access
Storm-2372 and the Russian campaigns Volexity tracked all rely on device-code phishing. Unless you have remote IoT devices that authenticate via device-code, block the flow. Note this also affects some legitimate scenarios (some printer auth, some old PowerShell modules) - test in report-only mode first.
Connect-MgGraph -Scopes "Policy.ReadWrite.ConditionalAccess"
$params = @{
DisplayName = "Block device-code flow (with Helpdesk exception)"
State = "enabledForReportingButNotEnforced" # change to "enabled" after pilot
Conditions = @{
ClientAppTypes = @("all")
Applications = @{ IncludeApplications = @("All") }
Users = @{
IncludeUsers = @("All")
ExcludeGroups = @("<helpdesk-break-glass-group-id>")
}
AuthenticationFlows = @{
TransferMethods = "deviceCodeFlow"
}
}
GrantControls = @{
Operator = "OR"
BuiltInControls = @("block")
}
}
New-MgIdentityConditionalAccessPolicy -BodyParameter $params
4 Conditional Access for workload identities (Entra ID P1+)
Service principals can be put under CA policies just like users. Common pattern: high-privilege SPs only authenticate from named locations (your build farm, your hosted automation tenant). Stops a stolen client secret from being usable from the open internet. Requires Entra ID Workload Identities Premium.
$params = @{
DisplayName = "SPs from corporate egress only"
State = "enabled"
Conditions = @{
ClientAppTypes = @("all")
Applications = @{ IncludeApplications = @("All") }
ClientApplications = @{
IncludeServicePrincipals = @("ServicePrincipalsInMyTenant")
}
Locations = @{
IncludeLocations = @("All")
ExcludeLocations = @("<named-corporate-egress-id>")
}
}
GrantControls = @{
Operator = "OR"
BuiltInControls = @("block")
}
}
New-MgIdentityConditionalAccessPolicy -BodyParameter $params
5 Make Continuous Access Evaluation strict
CAE is on by default in opportunistic mode: if the client supports it, near-real-time revocation works; if not, it falls back to one-hour tokens. Strict enforcement mode requires CAE-aware clients and rejects access otherwise - close to a hard guarantee that revoking a refresh token actually evicts the attacker. Trade-off: legacy clients that haven't been updated to be CAE-aware will break. Pilot first.
Configure under Conditional Access → Session controls → Customize continuous access evaluation → Strictly enforce location policies.
6 Audit Configurable Token Lifetime policies
Microsoft's default access-token TTL is a randomized 60-90 minutes (or 2 hours flat for Teams / M365 in non-CA tenants). Tenants can override this via Configurable Token Lifetime (CTL) policies, with the maximum being 24 hours. Long-lived tokens improve UX for long-running scripts but directly proportionally widen the post-token-theft exposure window: a 24h CTL means a stolen token issued at 09:00 stays valid until 09:00 the next day, regardless of any password reset, account disablement, or session revocation you trigger in between (per the Remediation note - access tokens cannot be revoked, only CAE narrows this for CAE-aware resources). Audit your tenant's CTL policies and challenge any that exceed two hours:
``` Connect with Policy.Read.All ```
Connect-MgGraph -Scopes "Policy.Read.All"
``` List every token-lifetime policy in the tenant and parse the AccessTokenLifetime value ```
Get-MgPolicyTokenLifetimePolicy | ForEach-Object {
$def = $_.Definition[0] | ConvertFrom-Json
$atl = $def.TokenLifetimePolicy.AccessTokenLifetime
[PSCustomObject]@{
DisplayName = $_.DisplayName
Id = $_.Id
IsOrganizationDefault = $_.IsOrganizationDefault
AccessTokenLifetime = $atl ``` "HH:MM:SS" or "D.HH:MM:SS" ```
AppliedToServicePrincipals = (Get-MgServicePrincipal -All |
Where-Object { (Get-MgServicePrincipalTokenLifetimePolicy -ServicePrincipalId $_.Id -ErrorAction SilentlyContinue).Id -contains $_.Id } |
ForEach-Object DisplayName)
}
} | Format-Table -AutoSize
``` Anything >= 02:00:00 deserves a documented business reason. ```
``` Anything >= 23:00:00 (the ~24h max) significantly extends post-incident exposure. ```
Refresh-token and session-token lifetimes have not been CTL-configurable since 30 January 2021 - those are managed exclusively via Conditional Access sign-in frequency, with the default being a 90-day rolling window. Reduce sign-in frequency for high-risk groups (privileged roles, finance, executives) to shorten the refresh-token survival window; pair with CAE strict mode (control 5) so refresh-token revocation actually evicts.
7 Review and remove unowned / unsafe app ownerships
The "Misowned and Dangerous" attack is impossible if no standard user owns an SP that has a privileged role. Hunt for the combination, then either remove the user's ownership or remove the privileged role from the SP.
Connect-MgGraph -Scopes "Application.Read.All","Directory.Read.All",
"RoleManagement.Read.Directory"
$privilegedRoleIds = (Get-MgDirectoryRole | Where-Object {
$_.DisplayName -in @(
"Global Administrator","Privileged Role Administrator",
"Application Administrator","Cloud Application Administrator",
"Privileged Authentication Administrator","User Administrator",
"Exchange Administrator","SharePoint Administrator")
}).Id
$findings = @()
foreach ($sp in Get-MgServicePrincipal -All) {
$assignments = Get-MgRoleManagementDirectoryRoleAssignment `
-Filter "principalId eq '$($sp.Id)'"
if (-not $assignments) { continue }
$privAssignments = $assignments | Where-Object {
$_.RoleDefinitionId -in $privilegedRoleIds
}
if (-not $privAssignments) { continue }
$owners = Get-MgServicePrincipalOwner -ServicePrincipalId $sp.Id
foreach ($o in $owners) {
$findings += [pscustomobject]@{
ServicePrincipal = $sp.DisplayName
AppId = $sp.AppId
Roles = ($privAssignments.RoleDefinitionId -join ", ")
OwnerUpn = $o.AdditionalProperties.userPrincipalName
OwnerId = $o.Id
}
}
}
$findings | Sort-Object ServicePrincipal | Format-Table -AutoSize
8 Enable Microsoft Graph activity logs
Off by default. Without them you can prove the malicious app got a token but not what it did with it. Stream MicrosoftGraphActivityLogs via Azure Monitor diagnostic settings to whatever destination feeds your Splunk index (typically Event Hub for the Splunk Add-on for Microsoft Cloud Services, or a Storage Account for the older HTTP Event Collector flow); budget for ~10-20% extra volume on a busy tenant.
# Enable diagnostic setting at tenant scope. The category names below are
# Azure Monitor diagnostic categories (not Splunk sourcetypes); they stay the
# same regardless of which destination you ship them to.
$diag = @{
name = "GraphActivityToSplunk"
eventHubAuthRule = "/subscriptions/<sub>/resourceGroups/<rg>/providers/" +
"Microsoft.EventHub/namespaces/<ns>/authorizationRules/RootManageSharedAccessKey"
eventHubName = "azure-diagnostics"
logs = @(
@{ category = "MicrosoftGraphActivityLogs"; enabled = $true }
@{ category = "AuditLogs"; enabled = $true }
@{ category = "SignInLogs"; enabled = $true }
@{ category = "NonInteractiveUserSignInLogs"; enabled = $true }
@{ category = "ServicePrincipalSignInLogs"; enabled = $true }
)
}
# Apply via the Entra portal: Identity > Monitoring & health > Diagnostic settings >
# Add diagnostic setting > select all categories > route to your Event Hub /
# Storage Account, then point the Splunk Add-on for Microsoft Cloud Services at it.
9 Audit Intune / DeviceManagementConfiguration permissions
Mandiant's 2024 research showed that an SP holding DeviceManagementConfiguration.ReadWrite.All can push scripts to Intune-managed Privileged Access Workstations and execute arbitrary code as SYSTEM. Treat this scope, plus RoleManagement.ReadWrite.Directory, AppRoleAssignment.ReadWrite.All, and Application.ReadWrite.All, as Tier-0 - inventory every SP with them.
$tier0Scopes = @(
"DeviceManagementConfiguration.ReadWrite.All",
"RoleManagement.ReadWrite.Directory",
"AppRoleAssignment.ReadWrite.All",
"Application.ReadWrite.All",
"Directory.ReadWrite.All",
"Mail.ReadWrite", "Files.ReadWrite.All"
)
$graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
$tier0AppRoleIds = $graphSp.AppRoles |
Where-Object { $_.Value -in $tier0Scopes } |
Select-Object Id, Value
Get-MgServicePrincipal -All | ForEach-Object {
$sp = $_
$assignments = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id |
Where-Object { $_.AppRoleId -in $tier0AppRoleIds.Id }
foreach ($a in $assignments) {
[pscustomobject]@{
Sp = $sp.DisplayName
AppId = $sp.AppId
Scope = ($tier0AppRoleIds | Where-Object { $_.Id -eq $a.AppRoleId }).Value
Resource = $a.ResourceDisplayName
}
}
} | Format-Table -AutoSize
10 Cross-tenant access settings - default to deny
Cross-tenant access settings define which other tenants can access yours. The default is permissive (any tenant). Lock this down: explicit allow-list of partner tenants, with B2B collaboration restricted to specific groups.
Configure under Entra admin center → External Identities → Cross-tenant access settings → Default settings. Set inbound and outbound to Block access, then add explicit organisation entries for each legitimate partner.
11 Quarterly OAuth review checklist
Cadence ritual. Block 90 minutes per quarter for the IAM team:
- Pull all Tier-0 SPs via the script in step 8 - do all of them still need that scope?
- Owner audit via step 6 - any new owners in the last 90 days?
- Stale credentials - any SP with a client secret older than your rotation policy? Any SP with credentials it has not used in >90 days?
- Verified publisher gap - which non-verified-publisher apps have any user consents? Move them to admin-consent.
- Cross-tenant settings - new partner tenants added since last review? Decommissioned but still trusted?
- OAuthSentry feed - is the daily ingestion working? When was the last malicious-feed update detected by your SIEM?
12 Continuous validation with Maester
The quarterly checklist above is human-paced and forgets things. Maester (Merill Fernando, Fabian Bader, Thomas Naunheim, Mike Soule and contributors) is an open-source PowerShell + Pester test framework that turns each Entra security control into a daily-runnable test - currently used by 128,000+ tenants. The relevant tests for the controls in this tab ship out of the box; pin them to a CI pipeline and you get a fail-the-build signal the moment one regresses.
| OAuthSentry control | Maester test that validates it |
|---|---|
| 1. Restrict user consent (this tab) | Test-MtCisaAppUserConsent, EIDSCA.AP08, EIDSCA.AP09, EIDSCA.CP03, EIDSCA.CP04 |
| 1. Admin-consent workflow enabled | EIDSCA.CR01, EIDSCA.CR02, EIDSCA.CR03, EIDSCA.CR04 |
| 2. App instance property lock | Test-MtServicePrincipalsForAllUsers (third-party SPs open to all users) |
| 4. Workload identity Conditional Access | Test-MtSpExchangeAppAccessPolicy (SPs with Exchange permissions have ApplicationAccessPolicy) |
| 6. Application ownership audit | Test-MtXspmAppRegWithPrivilegedApiAndOwners |
| 3, 5. CA hygiene (block device-code, CAE strict) | Test-MtCisaBlockHighRiskSignIn, Test-MtCisaBlockHighRiskUser, plus the full -Tag CA suite |
Install-Module Maester -Scope CurrentUser md ~/maester-tests; cd ~/maester-tests Install-MaesterTests # pulls EIDSCA + CISA + Maester tests Connect-Maester # interactive sign-in, requests read-only Graph scopes Invoke-Maester # runs everything, writes ./test-results/TestResults.html Invoke-Maester -Tag App # OAuth/app-related tests only # Production: wire it into GitHub Actions or Azure DevOps with workload identity # federation (no secrets) per https://maester.dev/docs/monitoring/ - daily run, # email/Teams notification on regression.
For Conditional Access specifically, Jasper Baes' Conditional Access Validator generates Maester tests automatically from your existing CA policies - useful when you've inherited a tenant and want a baseline of what's currently enforced before changing anything.
OAuth abuse against Google Workspace beta
Google Workspace's OAuth threat surface is smaller than Entra's but the same class of attack works: a user grants a third-party app access to Gmail, Drive or Contacts, and the attacker exfiltrates from cloud-to-cloud without ever touching the endpoint. The two in-the-wild cases that drive OAuthSentry's malicious feed today (Salesloft Drift / UNC6395 in August 2025, and Context.ai / Vercel in April 2026) both followed this pattern. This subtab covers the audit trail, detection queries and revocation tooling specific to Google.
1 Tradecraft and known-bad apps
The attack pattern Google Workspace defenders should expect:
- Vendor compromise pivot. Attacker steals OAuth tokens from a SaaS integration vendor (Salesloft Drift, Context.ai, marketing automation tools) and uses those tokens to read Gmail / Drive / Contacts at scale across every customer that consented to the integration. Detection has to start at the token activity level - the user did consent legitimately, just to a now-compromised vendor.
- Mass-mailer phishing apps. Lookalike apps mimicking Google products that request
https://www.googleapis.com/auth/contacts+https://www.googleapis.com/auth/gmail.send. Once consented, the app reads contacts, sends the same consent prompt to every contact, and propagates virally. The May 2017 "Google Docs" worm is the canonical example - the attack still works because the consent prompt is generic. - Broad-mailbox-access scope. The legacy
https://mail.google.com/scope grants the holder full Gmail access (read, send, modify, delete) and is the scope used by IMAP/SMTP/POP clients. Apps requesting this scope are almost always either legitimate desktop mail clients (Outlook, Thunderbird, eM Client, Apple Mail) or attacker tools mimicking them. The detection clue is the combination ofclient_typeand the scope set: aWEBclient requestingmail.google.comis rare and worth investigating.
The two app IDs OAuthSentry currently classifies as malicious for Google Workspace (curated by mthcht/awesome-lists):
| App | Client ID | Why |
|---|---|---|
| Context.ai / AI Office Suite | 110671459871-30f1spbu0hptbs60cb4vsmv79i7bbvqj.apps.googleusercontent.com | OAuth client behind Context.ai's deprecated AI Office Suite (consumer product). Compromised in March 2026; Vercel disclosed on April 19, 2026 that an attacker used a stolen Context.ai OAuth token to pivot into a Vercel employee's Google Workspace account and from there into Vercel's environment. Wiz and Context.ai's own statement confirmed the OAuth token leak. Vercel published this client ID as the canonical IOC. |
| Salesloft Drift Email | 1084253493764-ipb2ntp4jb4rmqc76jp7habdrhfdus3q.apps.googleusercontent.com | Salesloft Drift's Google Workspace OAuth integration. UNC6395 (tracked by Google Threat Intelligence Group) used compromised Drift OAuth tokens to access Gmail data on August 9, 2025; the broader Salesforce campaign ran August 8-18. Google revoked all Drift Email OAuth tokens on August 29, 2025, so the active-threat window is closed - hunt your historical token / login audit logs for any pre-revocation activity tied to this client ID to assess past exposure. Astrix and WideField independently published this client ID as an IOC. |
2 Audit trail - the Admin SDK Reports API
Every OAuth grant, revocation and token-driven API call against a Google Workspace tenant is recorded in the Admin SDK Reports API. The events live under different applicationName values - the OAuth ones defenders care about are:
| applicationName | What it records |
|---|---|
| token | OAuth grants, revocations, token requests and per-call activity. The single highest-value source for OAuth hunting. Four event names, all with type=auth: authorize, revoke, request, activity. |
| login | User sign-ins, including SSO via OAuth federation. Useful for correlating consent-prompt acceptance with the source IP and device. |
| admin | Admin Console actions. Pivot here to see if an admin granted domain-wide delegation, changed an OAuth app trust setting, or whitelisted a client ID. |
The shape of an authorize event in the token application:
{
"kind": "audit#activity",
"id": { "time": "2026-04-27T07:20:47.123Z", "uniqueQualifier": "...",
"applicationName": "token", "customerId": "C0<tenant>" },
"actor": { "email": "user@yourdomain.com", "profileId": "<profile-id>",
"callerType": "USER" },
"ipAddress": "203.0.113.42",
"events": [
{
"type": "auth",
"name": "authorize",
"parameters": [
{ "name": "client_id", "value": "1084253493764-ipb2ntp4jb4rmqc76jp7habdrhfdus3q.apps.googleusercontent.com" },
{ "name": "app_name", "value": "Drift Email" },
{ "name": "client_type", "value": "WEB" },
{ "name": "scope", "multiValue": [
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/userinfo.email",
"openid"
] },
{ "name": "scope_data", "multiMessageValue": [...] }
]
}
]
}
The four fields defenders pivot on, with paths confirmed against the shape above:
- OAuth client ID (the equivalent of Entra's AppId) -
events[].parameters[]wherename=="client_id". Lower-case it and match against OAuthSentry'sfeeds/google/google_malicious.txt. Google client IDs are not GUIDs - they end in.apps.googleusercontent.comand the leading numeric segment is the project number. - Granted scopes -
events[].parameters[]wherename=="scope",multiValuefield is the array.https://mail.google.com/,https://www.googleapis.com/auth/gmail.send,https://www.googleapis.com/auth/driveandhttps://www.googleapis.com/auth/contactsare the four high-impact reads/writes; the.readonlyvariants matter for exfil hunts. - Consenting user - top-level
actor.email+actor.profileId. Pair with theloginapplication events on the sameprofileIdto recover the IP, device and 2SV factor used at consent time. - Client type -
events[].parameters[]wherename=="client_type". Per Google's reference, possible values areWEB(browser-based OAuth flow with a consent prompt),NATIVE_DESKTOP(installed desktop client like Outlook, Thunderbird, eM Client),NATIVE_ANDROID,NATIVE_IOS,NATIVE_CHROME_EXTENSION,NATIVE_APPLICATION,CONNECTED_DEVICE, and a handful of platform-specific values plusTYPE_UNSPECIFIED. The full enum is in the OAuth Token Audit Activity Events reference. Domain-wide delegation impersonation does not appear here as aclient_typevalue - it shows up separately in the Admin Console under API controls.
3 Detection queries
Google Workspace audit data can be queried three ways: directly against the Reports API, via GAM (the open-source admin tool), or against your SIEM if you forward Workspace logs there (most do via Pub/Sub - Splunk or the native Workspace Splunk add-on). Examples for each:
Detection 1: any consent grant for a known-malicious app, via Reports API directly. A read-only GCP service account with the https://www.googleapis.com/auth/admin.reports.audit.readonly scope can poll this on a 5-minute cadence:
``` Get the last hour of authorize events across the tenant ```
ACCESS_TOKEN=$(gcloud auth application-default print-access-token)
START=$(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%S.000Z)
curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
"https://admin.googleapis.com/admin/reports/v1/activity/users/all/applications/token?eventName=authorize&startTime=$START" \
| jq -r '
.items[]
| . as $item
| .events[]
| select(.name == "authorize")
| [
$item.id.time,
$item.actor.email,
$item.ipAddress,
((.parameters[] | select(.name == "client_id") | .value) // ""),
((.parameters[] | select(.name == "app_name") | .value) // ""),
((.parameters[] | select(.name == "scope") | .multiValue | join(";")) // "")
]
| @csv
' \
| awk -F',' 'BEGIN { while((getline l < "/path/to/feeds/google/google_malicious.txt") > 0) bad[tolower(l)]=1 }
{ id=tolower($4); gsub(/"/, "", id); if (bad[id]) print "MALICIOUS:", $0 }'
Detection 2: any consent for a high-impact scope, via GAM. GAM wraps the Reports API and is faster to iterate than raw curl. The exact GAM report token flag syntax for time ranges and event filters varies between GAM 6 / GAM 7 / GAMADV-XTD3 - consult the Reports wiki for your installed version. The two reliably-portable token commands across versions are showing and revoking tokens directly:
``` Per-user view of currently-granted tokens (run before mass revocation) ``` gam user user@yourdomain.com show tokens ``` Tenant-wide token inventory: every client_id every user has authorized ``` gam all users print tokens ``` Filter the inventory to a single client (e.g. an OAuthSentry malicious entry) ``` gam all users print tokens clientid 1084253493764-ipb2ntp4jb4rmqc76jp7habdrhfdus3q.apps.googleusercontent.com
For ad-hoc OAuth-token report queries against the Admin SDK, the curl approach in Detection 1 is the most stable across environments since it speaks directly to the documented API.
Detection 3: SIEM-side, if you ingest Workspace logs. Most Workspace customers forward Reports API events into a SIEM via Pub/Sub or a vendor add-on. Field names in the SIEM mirror the Reports API JSON, so the pattern is identical to Detection 1; the example below uses Splunk syntax with the Workspace add-on's typical field naming - verify the sourcetype and exact field paths in your environment before deploying, since add-on conventions differ:
index=gws sourcetype=gws:reports:token name=authorize
| eval client_id = lower(mvindex(parameters_value, mvfind(parameters_name, "client_id")))
| eval app_name = mvindex(parameters_value, mvfind(parameters_name, "app_name"))
| eval client_type = mvindex(parameters_value, mvfind(parameters_name, "client_type"))
| eval scopes = mvjoin(json_extract(parameters, "$..multiValue[*]"), ", ")
| lookup oauthsentry_google_malicious appid as client_id
OUTPUT category as oas_category, severity as oas_severity, comment as oas_comment
| where oas_category = "malicious"
| stats count
earliest(_time) as first_seen
latest(_time) as last_seen
values(actor_email) as users
values(ipAddress) as src_ips
values(scopes) as scopes
by client_id, app_name, client_type, oas_severity, oas_comment
| convert ctime(first_seen) ctime(last_seen)
4 Remediation - revoking OAuth tokens
Google's revocation model is per-(user, client_id). There is no tenant-wide "block this app" toggle that retroactively kills existing tokens; you must enumerate and revoke. The two paths:
Mass revocation via GAM (the right tool when an app is on OAuthSentry's malicious list and you need to evict it from every user in the domain in one shot):
``` Single user, single client - the targeted revocation ``` gam user victim@yourdomain.com delete token clientid \ 1084253493764-ipb2ntp4jb4rmqc76jp7habdrhfdus3q.apps.googleusercontent.com ``` Tenant-wide: revoke this client across every user ``` gam all users delete token clientid \ 1084253493764-ipb2ntp4jb4rmqc76jp7habdrhfdus3q.apps.googleusercontent.com ``` Audit trail: confirm the revocations landed by re-listing tokens for the client ``` ``` (a successful mass-revoke means this returns no rows for any user) ``` gam all users print tokens clientid 1084253493764-ipb2ntp4jb4rmqc76jp7habdrhfdus3q.apps.googleusercontent.com
Admin Console path (one-off use; for muscle-memory or when you don't have GAM deployed):
- Admin Console → Security → Access and data control → API controls.
- Manage Third-Party App Access: search for the client ID, set Access to Blocked. This prevents future grants but does not revoke existing tokens - GAM still required for that.
- Domain-wide delegation: if the malicious client appears here, remove the entry. DWD entries grant the app the ability to impersonate any user without per-user consent; presence here is critical-severity.
- Reports → Audit and investigation → Token log events (the OAuth audit view; menu name has changed across Workspace editions, look for "Token" or "OAuth"): confirm the
revokeevents appeared for every user that previously had a token.
5 Hardening - the controls that prevent the next consent attack
- Block unverified apps tenant-wide. Admin Console → Security → Access and data control → API controls → App access control. Set the default access for unconfigured third-party apps to blocked (the exact toggle label varies between Workspace editions and has been renamed by Google over time - the Red Canary OAuth-attack writeup walks through the current panel layout). With the default blocked, a user can only consent to apps that an admin has explicitly trusted.
- Restrict access to the four high-impact services. Same screen, set Gmail, Drive and Docs, Contacts and Calendar to "Restricted" rather than "Unrestricted". Restricted means only Trusted apps can request scopes for that service; everything else is blocked even if the user clicks accept.
- Audit domain-wide delegation quarterly. Admin Console → Security → API controls → Domain-wide delegation. Every entry is an unconditional impersonation grant. The list should be short, named, and every entry should map to a documented service. Unknown client IDs here are an incident.
- Forward Workspace audit logs out of Google. Workspace audit log retention varies by data type and edition (the OAuth Token Reports API is documented to expose 180 days; in-console audit log views may surface different windows). Forward the events out via Pub/Sub or a vendor add-on into a SIEM with longer retention so consent events from a year ago are still queryable when an attack surfaces. Confirm your edition's actual retention in the Admin Help center before relying on it.
- Subscribe to OAuthSentry's
feeds/google/google_malicious.txtand run the Detection 1 curl on a 15-minute cadence. The list is small today (two entries) but every new compromise lands here within a day of public disclosure.
OAuth abuse against GitHub beta
GitHub's OAuth threat surface is structurally different from Entra and Google: instead of a tenant-hosted SP that survives revocation events, GitHub OAuth Apps are owned and operated by their publisher (Heroku, Travis CI, etc.), and a single compromise of the publisher exposes every customer org that authorized that app. The April 2022 Heroku/Travis CI incident, which drives OAuthSentry's seed list for this service today, is the canonical example.
1 Tradecraft and known-bad apps
Three patterns GitHub defenders should expect. The first one is the high-impact threat:
- Publisher compromise of a popular OAuth integrator. Attacker breaches a SaaS vendor that holds OAuth user tokens for many GitHub orgs (CI/CD platform, deployment tool, code-review service, security scanner) and then enumerates and clones every private repo those tokens grant access to. The user did consent legitimately - the threat actor inherits trust from a now-compromised vendor. Detection has to start at the audit log level, looking at which apps have ever been authorized in your org.
- Stolen Personal Access Tokens (PATs). Not strictly OAuth, but adjacent: a developer commits a PAT, leaks it through an env-var dump, or has it stolen via stealer malware. The token is then used to read private repos or push malicious commits. Hunt with
action:personal_access_tokenevents and thehashed_tokenfield in audit log entries. - Malicious GitHub App installation. An attacker convinces an org owner to install a GitHub App they control. Different threat model from OAuth Apps - GitHub Apps act as themselves with installation tokens, not as the consenting user, and have fine-grained permissions. The audit signal is
integration_installation.createfor an unknown app slug.
The four GitHub OAuth App names OAuthSentry currently classifies as malicious-historical (curated from GitHub's April 2022 disclosure):
| App name (matchable in audit logs) | Numeric OAuth App ID(s) | Status |
|---|---|---|
| Heroku Dashboard | 145909, 628778 | Tokens stolen and abused April 2022. Heroku/GitHub revoked all tokens April 13-16 2022 and Heroku stopped issuing new tokens from the Heroku Dashboard integration. Active threat window is closed; remains valuable as a historical IOC for hunting pre-revocation activity in retained audit logs. |
| Heroku Dashboard - Preview | 313468 | |
| Heroku Dashboard - Classic | 363831 | |
| Travis CI | 9216 | Tokens stolen via the Heroku-side compromise; Travis CI revoked all authorization keys and tokens April 15 2022. Same hunting guidance as Heroku Dashboard variants. |
client_id string on every authorize event), GitHub's audit log emits oauth_application_name on the oauth_authorization.* and oauth_access.* action families - and does not include the numeric OAuth App ID on those events. The numeric IDs above are the canonical identifiers from GitHub's UI (Settings → Developer settings → OAuth Apps), which GitHub itself published as IOCs in their disclosure. They are useful for absolute identification but for audit-log hunting, you match on the name. OAuthSentry's feeds/github/github_malicious.txt ships the names for that reason.
2 Audit trail - the GitHub audit log REST API
OAuth App authorizations, revocations and token generation events live in the org audit log (or enterprise audit log, for Enterprise Cloud customers). Two REST API endpoints expose them:
| Scope | Endpoint |
|---|---|
| Single org | https://api.github.com/orgs/{org}/audit-log |
| Enterprise (all orgs) | https://api.github.com/enterprises/{enterprise}/audit-log |
Required PAT or OAuth-app scope is read:audit_log (introduced December 2022 - prior to that, full admin:org was needed). The audit log retains most events for up to 180 days; Git events are retained for 7 days only, and the in-UI view defaults to the past 3 months.
The OAuth-relevant action names defenders should hunt on:
| Action | What it records | Key fields |
|---|---|---|
| oauth_authorization.create | A user authorized a third-party OAuth App against their account/org. | actor, oauth_application_name, created_at |
| oauth_authorization.update | An existing authorization was modified (typically scope expansion). | actor, oauth_application_name |
| oauth_authorization.destroy | An authorization was revoked. | actor, oauth_application_name, explanation |
| oauth_access.generate | A new OAuth access token was issued under an existing authorization. | actor, oauth_application_name, token_id, token_scopes, hashed_token |
| oauth_access.regenerate | An existing OAuth access token was rotated (the authorization stays, the token is reissued). | actor, oauth_application_name, token_id, token_scopes, hashed_token, actor_ip |
| integration_installation.create | A GitHub App (not OAuth App) was installed in the org. | actor, integration name |
| personal_access_token.access_granted | A fine-grained PAT was granted access to org resources. | actor, token_id, token_scopes |
An anonymized reference event - what one of these actions actually looks like in a Cloud enterprise's audit log:
{
"@timestamp": 1777298667221,
"_document_id": "<document-id>",
"action": "oauth_access.regenerate",
"actor_ip": "<source-ip>",
"actor_location": { "country_code": "<cc>" },
"business": "<enterprise-slug>",
"business_id": <enterprise-id>,
"created_at": 1777298667221,
"hashed_token": "<sha256-of-token>",
"oauth_application_name": "<internal-app-name>",
"operation_type": "modify",
"request_access_security_header": null,
"request_id": "<request-id>",
"token_id": <token-id>,
"token_scopes": "read:org,user:email",
"user": "<username>",
"user_agent": "Go-http-client/2.0",
"user_id": <user-id>
}
The four fields defenders pivot on, with paths confirmed against the shape above:
- OAuth App identifier →
oauth_application_nameat the top level. Lower-case and match against OAuthSentry'sfeeds/github/github_malicious.txt. As noted above, the numeric OAuth App ID is not emitted on these events - the name is the only matchable identifier. - Granted scopes →
token_scopesas a comma-separated string (e.g.read:org,user:email).repo,repo:write,workflow,admin:org,delete_repoare the high-impact scopes; treat any new authorization that requests them as page-on-call. - Acting user and source →
user+user_id(top-level), pair withactor_ipandactor_location.country_codefor geo / VPN / hosting-provider checks. Theuser_agentis also informative - Go and Python HTTP clients on consent-prompt actions are unusual and worth investigating. - Token identity →
token_id(numeric) andhashed_token(URL-safe base64 SHA-256 of the raw token). Both are stable across the lifetime of the same token and let you correlate one consent event with every subsequentgit.clone,git.fetch, or repo-read action that used that token. To search for every event tied to a known-leaked token, use GitHub's hashed-token search.
3 Detection queries
Detection 1: any authorization or token-issuance event for a known-malicious app, via REST API. Poll the org or enterprise audit log on a 5-15 minute cadence using a service-account PAT with the read:audit_log scope. Match the oauth_application_name field against OAuthSentry's malicious feed (which contains names, not numeric IDs - see the schema callout above):
``` Pull the last hour of OAuth-lifecycle events for an org and match by name ```
ORG="your-org"
PAT="ghp_..." # PAT with read:audit_log scope
SINCE=$(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ)
``` All events whose action category is oauth_authorization or oauth_access ```
``` (covers create, update, destroy, generate, regenerate). Note +>= URL-encoding ```
curl -s \
-H "Authorization: Bearer $PAT" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/orgs/$ORG/audit-log?phrase=action:oauth_authorization+OR+action:oauth_access+created:>=$SINCE&per_page=100" \
| jq -r '.[] | [
(.["@timestamp"] / 1000 | strftime("%Y-%m-%dT%H:%M:%SZ")),
.action,
.user,
.actor_ip,
.oauth_application_name,
.token_scopes
] | @csv' \
| awk -F',' 'BEGIN { while((getline n < "/path/to/feeds/github/github_malicious.txt") > 0) if (n !~ /^#/) bad[tolower(n)]=1 }
{ name=tolower($5); gsub(/"/, "", name); if (bad[name]) print "MALICIOUS:", $0 }'
The same query works against /enterprises/{enterprise}/audit-log if you have enterprise-level access - that gives one query that covers every org in the enterprise.
Detection 2: gh CLI (lighter-weight version of the same hunt). The gh api command handles auth and pagination for you when running locally:
``` Last 7 days of OAuth authorization events, paginated ```
gh api --paginate \
-H "Accept: application/vnd.github+json" \
"/orgs/$ORG/audit-log?phrase=action:oauth_authorization+created:>=$(date -u -d '7 days ago' +%Y-%m-%d)&per_page=100" \
| jq '.[] | {ts: .["@timestamp"], actor, app: .oauth_application_name, action}'
Detection 3: SIEM-side, if you stream GitHub audit logs. GitHub Enterprise Cloud supports audit log streaming to S3, Azure Blob, GCS, Splunk, Datadog and others. Once landed, the field names match the REST response, so the same predicate works:
index=github sourcetype=github:audit action="oauth_authorization.create"
| eval app_name = lower(oauth_application_name)
| lookup oauthsentry_github_malicious appname as app_name
OUTPUT category as oas_category, severity as oas_severity, comment as oas_comment
| where oas_category = "malicious"
| stats count
earliest(_time) as first_seen
latest(_time) as last_seen
values(actor) as users
by app_name, oas_severity, oas_comment
| convert ctime(first_seen) ctime(last_seen)
Verify the sourcetype - github:audit is the typical Splunk Add-on for GitHub Enterprise sourcetype, but the field naming after parse-time depends on your specific add-on/parser configuration.
4 Remediation - revoking OAuth apps and tokens
GitHub's revocation model has three layers, and you need to use the right one depending on what you're trying to revoke:
- Org-level revocation of the OAuth App (the right answer for a known-malicious app). Org settings → Third-party access → OAuth app policy. If OAuth app access restrictions are enabled, you can deny the app outright; otherwise, click the app entry and revoke its access. This blocks future use across the entire org regardless of which user authorized it. From the REST API:
DELETE /orgs/{org}/credential-authorizations/{credential_id}(requires SAML SSO with theadmin:orgscope). - User-level revocation (for individual cleanup): the user can go to GitHub → Settings → Applications → Authorized OAuth Apps and revoke. From the REST API:
DELETE /applications/{client_id}/grant(requires Basic auth with the OAuth App's client ID + secret - app-owner credentials, not the user's). - Token-level revocation by the publisher. If the OAuth App publisher itself has been compromised (the Heroku/Travis CI scenario), only they can revoke the issued tokens via
DELETE /applications/{client_id}/token. Defenders cannot do this from the customer side - org-level revocation (above) is what disconnects your org from the compromised publisher.
5 Hardening - controls that prevent the next OAuth-driven incident
- Enable OAuth App access restrictions on every org. Org settings → Third-party access → toggle Restrict OAuth app access to this organization. With this on, OAuth Apps must be explicitly approved by an org owner before they can read org-level resources. Without this restriction, any member can authorize any OAuth App against their org access. GitHub's docs walk through the setup and the notification flow.
- Stream the audit log out of GitHub. Enterprise Cloud customers can stream to S3, Azure Blob, GCS, Splunk or Datadog. The native retention is 180 days for most events and 7 days for Git events; streaming is the only way to keep these queryable for a full year of incident response.
- Require SAML SSO and SCIM. Tying every authorization to a verified IdP identity makes the
actorfield in audit log events a real identity instead of a username, which dramatically improves attribution after an incident. - Use fine-grained PATs and short token lifetimes. Where you have to use long-lived credentials, prefer fine-grained PATs (per-resource scoping, mandatory expiry) over classic PATs with broad
reposcope. The audit log surfaces these aspersonal_access_token.*actions withtoken_idandtoken_scopesfields. - Subscribe to OAuthSentry's
feeds/github/github_malicious.txtand the human-readable name list to feed your audit-log hunts. The list is small today (five entries from one historical incident) but every publisher compromise added here lands within a day of public disclosure.
Feeds
Plain-text and CSV feeds for direct ingestion into SIEM, EDR allow/deny lists, KQL hunts, Splunk lookups and so on. Updated automatically from upstream sources via GitHub Actions. One App ID per line in .txt feeds.
Compliance
Legitimate first-party and well-known third-party apps. Use as a known-good baseline for triage and allowlists.
Risky
Legitimate apps repeatedly observed in BEC, exfiltration tooling (rclone, eM Client), or first-party Microsoft apps abused via AADInternals / EvilProxy / UTA0352. Hunt context.
Malicious
Confirmed malicious OAuth applications: APT29 redirect apps, EvilGinx/AiTM lures, RH-ISAC tracked impersonation campaigns, homoglyph SharePoint/OneDrive impersonators, and disclosed publisher compromises (Salesloft Drift, Context.ai, Heroku Dashboard).
Combined feeds
Cross-service exports. The all_index.json is what the search UI uses; use it for any custom integration that needs the full record per app.
JSON
Full schema, every service, ready for programmatic consumption.
Changelog feed
Subscribe to catalog changes - adds, removes, category shifts, severity escalations - in real time. Standard Atom feed for any RSS reader, SIEM RSS connector, or chat-bot integration; JSON twin for programmatic consumers. The changelog is the push-style counterpart to the request/response API: instead of polling lookup_by_appid.json daily and diffing it yourself, subscribe and let your tooling pull on update.
Subscribe
Atom 1.0 standard format. Every entry covers one catalog change with the appid, service, category, severity and a human-readable summary; categorized by change kind (added, removed, category-changed, severity-raised, reference-added) so consumers can filter on what they care about.
Use cases
Pipe into Slack/Teams via the built-in RSS app for immediate awareness of new malicious app entries; refresh a Splunk lookup table or Sentinel watchlist on every added/category-changed event so detections stay current without manual export-import; alert on first appearance of a severity-raised entry that escalates a previously-known compliance app to risky/malicious.
``` Pull and pretty-print the JSON twin (entries sorted newest first) ```
curl -s https://oauthsentry.github.io/feeds/changelog.json | jq '.entries[:5]'
``` Filter for malicious-only changes in the last 24h ```
curl -s https://oauthsentry.github.io/feeds/changelog.json \
| jq --arg since "$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ)" '
.entries
| map(select(.category == "malicious" and .ts > $since))'
``` Watch for any new addition to the malicious bucket and write to a SIEM lookup ```
curl -s https://oauthsentry.github.io/feeds/changelog.json \
| jq -r '.entries[]
| select(.kind == "added" and .category == "malicious")
| [.ts, .service, .appid, .appname] | @csv' \
| tee -a /var/lib/oauthsentry/new_malicious.csv
History rolling-retains the last 200 entries; older changes drop off. The data/.last_build.json snapshot in the repo is what each new build diffs against - it must be committed back after each CI run so consecutive runs detect changes correctly.
Triage
Paste a list of OAuth App IDs, structured audit log JSON, or any raw log dump that mentions OAuth app IDs. Each identifier is matched against OAuthSentry's catalog and grouped by category - malicious first, then risky, then unknown (the bucket worth investigating because it's not in any list yet), then compliance (collapsed). Pure client-side; pasted content never leaves the browser.
Need this from a script, SOAR runbook, or alert pipeline? The same lookup is exposed as a static REST API - see the API tab for endpoints, the slug rule, and code samples.
Static REST API
Every entry in OAuthSentry's catalog is callable as a JSON endpoint, hosted on GitHub Pages.
No authentication, no rate-limit beyond GitHub's defaults, CORS open, served as application/json.
Useful for SOAR enrichment, alert pipelines, custom tooling - anywhere you need the catalog at runtime.
Endpoints
| Method & path | Purpose |
|---|---|
| GET /feeds/api/v1/apps/{slug}.json | Single-app record. 404 when the app is not in OAuthSentry's catalog (which itself is a useful signal - means uncategorized). |
| GET /feeds/api/v1/lookup_by_appid.json | Bulk lookup as { appid: record }. The right pattern for SIEM-side enrichment - fetch once, query in memory. |
| GET /feeds/api/v1/lookup.json | Bulk lookup keyed by slug instead of raw appid. |
| GET /feeds/api/v1/meta.json | Dataset metadata: total apps, breakdown by service and category, generated_at, schema version, the slug rule. |
Computing the slug
Lower-case the appid, then replace any run of characters outside [a-z0-9._-] with a single hyphen, and strip leading/trailing hyphens. Examples:
| Service | Raw appid | Slug |
|---|---|---|
| Entra | c5393580-f805-4401-95e8-94b7a6ef2fc2 | c5393580-f805-4401-95e8-94b7a6ef2fc2 (unchanged) |
| 1084253493764-ipb2ntp4...apps.googleusercontent.com | (unchanged - already URL-safe) | |
| GitHub | Heroku Dashboard | heroku-dashboard |
Response shape
Every endpoint returning an app record uses the same JSON schema:
{
"appid": "1084253493764-ipb2ntp4jb4rmqc76jp7habdrhfdus3q.apps.googleusercontent.com",
"appname": "Drift Email / Salesloft Drift Google Workspace OAuth app",
"service": "google",
"category": "malicious",
"severity": "critical",
"comment": "Astrix reported UNC6395 active operations through the Drift Email OAuth application...",
"references": [
"https://astrix.security/learn/blog/critical-update-astrix-research-team-discovers-unc6395-...",
"https://github.com/mthcht/awesome-lists/blob/main/Lists/OAuth/google_oauth_apps.csv"
],
"slug": "1084253493764-ipb2ntp4jb4rmqc76jp7habdrhfdus3q.apps.googleusercontent.com"
}
Example uses
``` Slug rule: lower-case the appid, replace any non-[a-z0-9._-] run with one hyphen ```
``` Entra GUID and Google client_id are already URL-safe; GitHub names need slugifying ```
curl -s https://oauthsentry.github.io/feeds/api/v1/apps/c5393580-f805-4401-95e8-94b7a6ef2fc2.json
curl -s https://oauthsentry.github.io/feeds/api/v1/apps/heroku-dashboard.json
curl -s https://oauthsentry.github.io/feeds/api/v1/apps/1084253493764-ipb2ntp4jb4rmqc76jp7habdrhfdus3q.apps.googleusercontent.com.json
``` Test the not-found behavior ```
curl -s -o /dev/null -w "%{http_code}\n" https://oauthsentry.github.io/feeds/api/v1/apps/totally-made-up.json
``` -> 404 (means uncategorized; worth investigating in your environment) ```
import json, requests
CATALOG_URL = "https://oauthsentry.github.io/feeds/api/v1/lookup_by_appid.json"
CATALOG = requests.get(CATALOG_URL, timeout=10).json()
def classify(appid: str) -> dict | None:
"""Returns None if appid is uncategorized."""
return CATALOG.get(appid.lower())
def on_consent_event(event):
record = classify(event["app_id"])
if record is None:
log_for_review(event, reason="uncategorized OAuth app")
return
if record["category"] == "malicious":
page_oncall(event, record)
elif record["category"] == "risky":
ticket_for_triage(event, record)
# compliance -> known-good, no action
``` 1. Download the malicious feed once a day on the search head ``` | inputlookup oauthsentry_malicious.csv | outputlookup oauthsentry_malicious.csv ``` (or use a scheduled curl + lookup definition - see Feeds page) ``` ``` 2. In any consent-event search, enrich inline ``` `oauthsentry_o365_audit` Operation="Consent to application" | eval appid = lower(ObjectId) | lookup oauthsentry_malicious appid OUTPUT category, severity, comment, references | where category = "malicious"
const slug = appid => appid.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-|-$/g, '');
async function classify(appid) {
const url = `https://oauthsentry.github.io/feeds/api/v1/apps/${slug(appid)}.json`;
const res = await fetch(url);
if (res.status === 404) return null; // uncategorized
if (!res.ok) throw new Error(`status ${res.status}`);
return res.json();
}
Why this works on GitHub Pages
GitHub Pages serves .json files with Content-Type: application/json and sets Access-Control-Allow-Origin: * by default, which makes static JSON files behave indistinguishably from a real REST API endpoint to any HTTP caller. The build pipeline (scripts/build_feeds.py) generates the entire feeds/api/v1/ tree on every catalog update, so every per-app endpoint and the bulk lookups are always in sync with the rest of the data.
Stability and versioning
The API is versioned in the URL path (/v1/). Breaking changes to the response schema or endpoint shapes will move to /v2/ with the previous version remaining available; non-breaking additions (new fields) land in /v1/. The slug rule is documented in meta.json as part of the contract - any change to that rule would also be a major version bump.
Methodology
OAuthSentry mirrors and consolidates publicly-curated OAuth threat-intel datasets. The categories follow defender intent, not vendor wording.
Compliance
An app is in compliance if it is a verified first-party app from the platform vendor, or a vetted third-party app with no observed abuse pattern. This bucket is reference data for hunting and allowlist tuning, not a "safe" verdict for any tenant.
Risky
An app is risky if it is legitimate but has been repeatedly observed in attacker tradecraft (mailbox sync clients, broad-scope cloud sync, first-party apps abused via offensive tooling). Detection logic should escalate based on context: tenant prevalence, scopes consented, source IP, geography.
Malicious
An app is malicious when at least one credible public report ties the App ID to an in-the-wild attack: consent phishing, AiTM session theft, or a known threat-actor cluster. Homoglyph apps impersonating Microsoft brands (Cyrillic Sharеpoint, OneDгive, etc.) are included.
Upstream credit
OAuthSentry stands on the shoulders of community-maintained lists. Primary sources currently mirrored:
- mthcht/awesome-lists - OAuth lists
- randomaccess3/detections - M365 OAuth detections
- Cyera-Research-Labs/m365-malicious-app-iocs
- anak0ndah/EntraHunt
- merill/microsoft-info - first-party app names
- Wiz - Detecting malicious OAuth applications
- RH-ISAC / Proofpoint - Microsoft OAuth impersonation
- Volexity - UTA0352/UTA0355 OAuth phishing