Detect OAuth consent phishing and malicious app registrations
For developers and responders who need to quickly identify risky OAuth grants and suspicious application registrations. This walks you through collecting consent and app inventory data, flagging high-risk patterns, and proving whether a registration or grant is malicious enough to revoke and contain.
TL;DR — OAuth consent phishing usually shows up as a newly registered app or newly granted OAuth client asking for broad scopes, using a suspicious redirect URI, weak publisher verification, or a tenant-external owner. Pull your app registrations and grants with your IdP API/CLI, sort for recent creations and high-risk scopes, then revoke the grant and disable or delete the app if the redirect URI, owner, and consent trail do not match a real deployment. Reading time: ~5 min
Goal
When you finish, you will have a repeatable triage workflow that exports OAuth application registrations and consent grants, highlights suspicious apps and scopes, and gives you concrete revoke/disable actions plus end-to-end verification that the malicious grant no longer works.
Prerequisites
- Admin or security-reader access to your identity provider tenant and audit logs
- Ability to revoke OAuth grants and disable or delete application registrations
jq >= 1.6— check withjq --versioncurl >= 8— check withcurl --version- One of these API access methods:
- Microsoft Entra / Azure AD:
az >= 2.60— check withaz version - Okta: an API token with app and system log read access
- Google Workspace: Admin SDK or Reports API access
- Microsoft Entra / Azure AD:
- A shell with
bash,grep,sort, andcolumn - The tenant domain, a time window to investigate, and at least one suspicious user report or alert timestamp
- A list of approved redirect URI domains for your org, for example
login.example.com,app.example.com,localhostfor dev only
Steps
Step 1: Export recent application registrations
Use your IdP API to pull app/client registrations created in the last 7 days.
For Microsoft Entra with Azure CLI:
SINCE="$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ)"
az login
az rest \
--method GET \
--url "https://graph.microsoft.com/v1.0/applications?$select=id,appId,displayName,createdDateTime,web,spa,publicClient,signInAudience,publisherDomain,verifiedPublisher&$filter=createdDateTime ge ${SINCE}" \
| jq '.value[] | {displayName,appId,createdDateTime,signInAudience,publisherDomain,verifiedPublisher,redirectUris: ((.web.redirectUris // []) + (.spa.redirectUris // []) + (.publicClient.redirectUris // []))}'
For Okta, list OIDC apps and filter locally by creation time:
OKTA_DOMAIN="https://your-org.okta.com"
API_TOKEN="REDACTED"
curl -sS -H "Authorization: SSWS ${API_TOKEN}" \
"${OKTA_DOMAIN}/api/v1/apps?limit=200" \
| jq '.[] | select(.signOnMode=="OPENID_CONNECT") | {label, id, created, redirectUris: .settings.oauthClient.redirect_uris, grantTypes: .settings.oauthClient.grant_types}'
You should see JSON objects with app name, client/app ID, creation time, and redirect URIs.
Step 2: Export recent OAuth consent grants
Pull delegated grants or tokens issued to clients during the same window.
For Microsoft Entra delegated grants:
az rest \
--method GET \
--url "https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$select=id,clientId,resourceId,scope,consentType,principalId,startTime,expiryTime" \
| jq '.value[] | {id,clientId,resourceId,scope,consentType,principalId,startTime,expiryTime}' \
> grants.json
For Okta, pull system log events related to app consent and app lifecycle:
SINCE="$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ)"
curl -sS -G -H "Authorization: SSWS ${API_TOKEN}" \
--data-urlencode "since=${SINCE}" \
--data-urlencode 'filter=eventType eq "application.lifecycle.create" or eventType eq "user.authentication.auth_via_IDP" or eventType sw "app.oauth2"' \
"${OKTA_DOMAIN}/api/v1/logs" \
| jq '.[] | {published, eventType, actor: .actor.alternateId, target: [.target[]?.displayName], outcome: .outcome.result}'
You should get a file or JSON stream containing client IDs, scopes, and timestamps you can correlate.
Step 3: Flag high-risk scopes and suspicious redirect URIs
Filter for the patterns that most often indicate consent phishing: broad scopes, non-corporate redirect domains, and localhost or custom schemes outside development.
For Microsoft Entra grants plus app inventory:
az rest \
--method GET \
--url "https://graph.microsoft.com/v1.0/applications?$select=id,appId,displayName,createdDateTime,web,spa,publicClient,verifiedPublisher" \
| jq -r '.value[] | [.appId, .displayName, .createdDateTime, ((.web.redirectUris // []) + (.spa.redirectUris // []) + (.publicClient.redirectUris // [])) | join(","), (.verifiedPublisher.verifiedPublisherId // "unverified")] | @tsv' \
> apps.tsv
jq -r '[.clientId, .scope, .consentType, (.principalId // "none")] | @tsv' grants.json \
| join -t $'\t' -1 1 -2 1 <(sort apps.tsv) <(sort) \
| awk -F'\t' '
$6 ~ /(Mail\.Read|Mail\.ReadWrite|Files\.ReadWrite\.All|offline_access|User\.Read\.All|Directory\.Read\.All|Directory\.AccessAsUser\.All)/ ||
$4 ~ /(localhost|127\.0\.0\.1|ngrok|web\.app|github\.io)/ ||
$5 == "unverified" {print}' \
| column -t -s $'\t'
Example output shape:
11111111-2222-3333-4444-555555555555 Invoice Viewer 2026-10-29T08:14:22Z https://invoicelogin-web.app/callback unverified Mail.Read offline_access Files.ReadWrite.All AllPrincipals none
You should see a short list of apps worth immediate triage, not your entire tenant.
Step 4: Validate the redirect URI and ownership trail
Check whether the redirect URI is live, expected, and controlled by your org.
curl -I -L --max-redirs 5 https://invoicelogin-web.app/callback
Suspicious output shape:
HTTP/2 302
location: https://login.example-authz.com/consent
server: nginx
HTTP/2 200
content-type: text/html
Now resolve the domain and inspect registration basics:
domain="invoicelogin-web.app"
getent hosts "$domain" || nslookup "$domain"
whois "$domain" | sed -n '1,25p'
Then pull app owners.
For Microsoft Entra:
APP_OBJECT_ID="REPLACE_WITH_APPLICATION_OBJECT_ID"
az rest --method GET --url "https://graph.microsoft.com/v1.0/applications/${APP_OBJECT_ID}/owners?$select=id,userPrincipalName,displayName" | jq '.value[]'
You should see either a known internal owner and approved domain, or enough mismatch to classify the app as malicious or unauthorized.
Step 5: Revoke the grant and disable the app
⚠️ Revoking grants or disabling an app can immediately break production sign-in flows and background jobs using that client. If the app is business-critical, revoke user consent first, then disable after confirming there is no approved owner.
For Microsoft Entra, delete the delegated grant and disable the service principal or application.
Delete a specific grant:
GRANT_ID="REPLACE_WITH_GRANT_ID"
az rest --method DELETE --url "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/${GRANT_ID}"
Disable the service principal used in sign-in:
SP_ID="REPLACE_WITH_SERVICE_PRINCIPAL_OBJECT_ID"
az rest \
--method PATCH \
--headers 'Content-Type=application/json' \
--url "https://graph.microsoft.com/v1.0/servicePrincipals/${SP_ID}" \
--body '{"accountEnabled":false}'
For Okta, deactivate the app:
APP_ID="REPLACE_WITH_OKTA_APP_ID"
curl -sS -X POST -H "Authorization: SSWS ${API_TOKEN}" \
-H 'Accept: application/json' \
"${OKTA_DOMAIN}/api/v1/apps/${APP_ID}/lifecycle/deactivate"
You should get an empty response or a success JSON object, and subsequent token requests should fail.
Step 6: Preserve evidence and add a detection rule
Save the app, grant, owner, and audit-log records before deleting anything permanently.
mkdir -p oauth-incident-$(date -u +%Y%m%dT%H%M%SZ)
cp grants.json oauth-incident-*/
cp apps.tsv oauth-incident-*/
Add a simple detector to your SIEM or scheduled job: alert on new apps with unverified publisher plus risky scopes or non-approved redirect domains.
{
"match": {
"created_within_hours": 24,
"verified_publisher": false,
"redirect_uri_domain_not_in": ["example.com", "login.example.com", "app.example.com", "localhost"],
"scopes_any": ["Mail.Read", "Mail.ReadWrite", "Files.ReadWrite.All", "offline_access", "Directory.Read.All"]
},
"severity": "high"
}
You should have an evidence folder and a detector definition ready to operationalize.
Verify it works
Run these checks after revocation.
- Confirm the grant is gone.
az rest --method GET --url "https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=clientId eq 'REPLACE_WITH_CLIENT_ID'" | jq '.value'
Expected result:
[]
- Confirm the app or service principal is disabled.
az rest --method GET --url "https://graph.microsoft.com/v1.0/servicePrincipals/REPLACE_WITH_SERVICE_PRINCIPAL_OBJECT_ID?$select=id,displayName,accountEnabled" | jq
Expected field:
{
"accountEnabled": false
}
- Confirm token issuance fails.
curl -sS -X POST https://login.microsoftonline.com/REPLACE_WITH_TENANT_ID/oauth2/v2.0/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'client_id=REPLACE_WITH_CLIENT_ID&scope=https%3A%2F%2Fgraph.microsoft.com%2F.default&grant_type=client_credentials&client_secret=REPLACE_WITH_SECRET'
Expected failure shape:
{
"error": "unauthorized_client"
}
- Confirm your detection catches a known-bad pattern by running the Step 3 filter against the latest 24 hours and seeing the suspicious app listed.
View as text
[User] ---- 1. consent ----> [OAuth App]
[OAuth App] ---- 2. token request ----> [IdP]
[IdP] ---- 3. grant issued ----> [OAuth App]
|
v
[Audit Logs]
|
v
[Detection Rule]
|
v
[Revoke/Disable]Common pitfalls
Filtering only application registrations, not service principals
Mistake: you inspect only app registrations and miss the enterprise app/service principal actually receiving consent.
Symptom: the app looks deleted or absent, but users can still sign in or tokens still issue.
Fix: query both applications and servicePrincipals, then disable the service principal object tied to the client ID.
Treating offline_access as harmless by itself
Mistake: you ignore offline_access because it is common.
Symptom: the app keeps access after the user closes the browser, and the incident persists quietly.
Fix: treat offline_access as high priority when combined with mail, files, directory, or broad read/write scopes.
Allowing localhost or custom URI schemes in production triage
Mistake: you dismiss http://localhost or myapp://callback because developers use them.
Symptom: a malicious public client hides among legitimate dev apps.
Fix: compare against your approved dev-client inventory; anything outside that list gets revoked first and justified later.
Revoking the grant but not rotating exposed client secrets
Mistake: you delete consent and stop there.
Symptom: the app still authenticates with client credentials or resumes access after re-consent.
Fix: disable the app, delete old secrets/certificates, and rotate any credentials that were present on the malicious or compromised registration.
Ignoring tenant-external owners or recently added owners
Mistake: you trust the display name and never inspect ownership.
Symptom: the app name matches an internal project, but the owner is an unexpected contractor, guest, or newly added account.
Fix: list owners explicitly and compare creation time, owner-add events, and redirect-domain ownership before restoring access.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI