LoFP LoFP / onboarding of a sanctioned mail client, add-in, backup, archiving, crm, calendar or sharepoint application that legitimately requires delegated access with a refresh token. validate publisher verification, application ownership (esql.app_owner_type) and whether the scopes align with an approved business use case, then add an exception for the reviewed application. microsoft first-party developer tooling such as graph explorer or microsoft graph command line tools may legitimately request mailbox or file scopes when used by it staff. restricting end-user consent to verified publishers and low-risk permissions reduces this residual set to unsanctioned applications only.

Techniques

Sample rules

Description

Identifies an end-user (non-admin) consent grant in Microsoft Entra ID to an application requesting delegated scopes commonly abused in illicit consent grant (OAuth phishing) attacks, such as mail, mailbox settings, contacts, files, SharePoint, OneNote, Teams chat and Exchange Web Services. Graph scopes are evaluated together with offline_access, which returns a long-lived refresh token and gives an attacker-controlled application durable, silent access to the victim’s data without stealing a password or re-prompting for MFA. The alert classifies the application owner as Microsoft first-party, in-tenant or external to help prioritize review.

Detection logic

FROM logs-azure.auditlogs-* metadata _id, _version, _index
| WHERE (azure.auditlogs.operation_name == "Consent to application"
    OR event.action == "Consent to application")
  AND event.outcome == "success"
  // End-user (non-admin) consent only
  AND TO_LOWER(`azure.auditlogs.properties.target_resources.0.modified_properties.0.new_value`) LIKE "*false*"

// Normalize ConsentAction.Permissions into a lower-cased, space-padded token string so " token " is an exact scope match.
// Raw: "[] => [[Id: .., ResourceId: .., ConsentType: Principal, Scope:  Mail.Read offline_access .., ..]]"
| EVAL Esql.scopes = CONCAT(" ", REPLACE(TO_LOWER(`azure.auditlogs.properties.target_resources.0.modified_properties.4.new_value`), """[\[\],;"]""", " "), " ")

// additional_details.key/value are parallel arrays but ES|QL returns multivalues sorted, so pair via MV_CONTAINS, not position.
// Owner = AppOwnerOrganizationId (Microsoft tenants) OR well-known first-party public-client AppIds (not always stamped with owner org).
| EVAL
    Esql.app_owner_type = CASE(
        MV_CONTAINS(azure.auditlogs.properties.additional_details.value, "f8cdef31-a31e-4b4a-93e4-5f571e91255a")   // Microsoft Services
          OR MV_CONTAINS(azure.auditlogs.properties.additional_details.value, "72f988bf-86f1-41af-91ab-2d7cd011db47") // Microsoft corp
          OR MV_CONTAINS(azure.auditlogs.properties.additional_details.value, "14d82eec-204b-4c2f-b7e8-296a70dab67e") // Microsoft Graph Command Line Tools
          OR MV_CONTAINS(azure.auditlogs.properties.additional_details.value, "de8bc8b5-d9f9-48b1-a8ad-b748da725064") // Graph Explorer
          OR MV_CONTAINS(azure.auditlogs.properties.additional_details.value, "04b07795-8ddb-461a-bbee-02f9e1bf7b46") // Azure CLI
          OR MV_CONTAINS(azure.auditlogs.properties.additional_details.value, "1950a258-227b-4e31-a9cf-717495945fc2") // Azure PowerShell
          OR MV_CONTAINS(azure.auditlogs.properties.additional_details.value, "c44b4083-3bb0-49c1-b47d-974e53cbdf3c") // Azure Portal
          OR MV_CONTAINS(azure.auditlogs.properties.additional_details.value, "aebc6443-996d-45c2-90f0-388ff96faa56"), "microsoft", // VS Code
        MV_CONTAINS(azure.auditlogs.properties.additional_details.value, azure.tenant_id), "tenant",
        "external"),
    // "Consent" = service principal was created by this consent, i.e. first time the app appears in the tenant
    Esql.sp_provisioned_by_consent = MV_CONTAINS(azure.auditlogs.properties.additional_details.value, "Consent")

// High-risk scope families: in-the-wild consent phishing (MS IR playbook), 365-Stealer/GraphRunner defaults, Midnight Blizzard EWS.
// Positive enumeration excludes low-risk variants (Mail.ReadBasic, Chat.ReadBasic, Files.*.Selected) without lookarounds.
| EVAL
    Esql.high_risk_scopes = TRIM(CONCAT(
        CASE(Esql.scopes RLIKE """.* mail\.(read|readwrite|send)(\.shared)? .*""", "mail ", ""),
        CASE(Esql.scopes RLIKE """.* mailbox(settings|item|folder)\.[a-z]+ .*""", "mailbox_settings ", ""),
        CASE(Esql.scopes RLIKE """.* contacts\.[a-z.]+ .*""", "contacts ", ""),
        CASE(Esql.scopes RLIKE """.* files\.(read|readwrite)(\.all|\.appfolder)? .*""", "files ", ""),
        CASE(Esql.scopes RLIKE """.* sites\.[a-z]+\.all .*""", "sites ", ""),
        CASE(Esql.scopes RLIKE """.* notes\.[a-z.]+ .*""", "notes ", ""),
        CASE(Esql.scopes RLIKE """.* (chat\.(read|readwrite|create)(\.all)?|chatmessage\.[a-z]+) .*""", "teams_chat ", ""),
        CASE(Esql.scopes RLIKE """.* (allsites\.[a-z]+|myfiles\.[a-z]+) .*""", "sharepoint_spo ", ""),
        CASE(Esql.scopes RLIKE """.* (ews\.accessasuser\.all|full_access_as_user) .*""", "exchange_ews ", ""))),
    // Low-risk scope families: recon / lower impact. ARM user_impersonation and Directory.AccessAsUser.All only count for
    // non-Microsoft apps (Azure CLI / PowerShell / Portal / VS Code consent noise).
    Esql.low_risk_scopes = TRIM(CONCAT(
        CASE(Esql.scopes RLIKE """.* people\.read(\.all)? .*""", "people ", ""),
        CASE(Esql.scopes RLIKE """.* calendars\.[a-z.]+ .*""", "calendars ", ""),
        CASE(Esql.scopes RLIKE """.* onlinemeetings\.[a-z]+ .*""", "online_meetings ", ""),
        CASE(Esql.scopes RLIKE """.* tasks\.[a-z.]+ .*""", "tasks ", ""),
        CASE(Esql.scopes RLIKE """.* user\.(read\.all|readbasic\.all) .*""", "user_directory ", ""),
        CASE(Esql.app_owner_type != "microsoft" AND Esql.scopes RLIKE """.* (user_impersonation|directory\.accessasuser\.all) .*""", "impersonation ", "")))
| EVAL
    Esql.high_risk_scope_count = CASE(Esql.high_risk_scopes == "", 0, MV_COUNT(SPLIT(Esql.high_risk_scopes, " "))),
    Esql.low_risk_scope_count = CASE(Esql.low_risk_scopes == "", 0, MV_COUNT(SPLIT(Esql.low_risk_scopes, " ")))

// Graph families require offline_access in the same event (durable access). SharePoint/EWS resource scopes are logged in a
// separate event without offline_access, so they alert on their own. External apps stacking 2+ low-risk scopes also alert.
| WHERE (Esql.scopes LIKE "* offline_access *" AND Esql.high_risk_scope_count >= 1)
    OR Esql.high_risk_scopes LIKE "*sharepoint_spo*"
    OR Esql.high_risk_scopes LIKE "*exchange_ews*"
    OR (Esql.scopes LIKE "* offline_access *" AND Esql.low_risk_scope_count >= 2 AND Esql.app_owner_type == "external")
| EVAL Esql.target_service_principal_id = `azure.auditlogs.properties.target_resources.0.id`
| DROP Esql.scopes
| KEEP
    _id,
    _version,
    _index,
    @timestamp,
    event.action,
    event.outcome,
    azure.tenant_id,
    azure.auditlogs.operation_name,
    azure.auditlogs.properties.correlation_id,
    azure.auditlogs.properties.initiated_by.user.userPrincipalName,
    azure.auditlogs.properties.initiated_by.user.id,
    azure.auditlogs.properties.initiated_by.user.ipAddress,
    `azure.auditlogs.properties.target_resources.0.display_name`,
    `azure.auditlogs.properties.target_resources.0.id`,
    `azure.auditlogs.properties.target_resources.0.modified_properties.4.new_value`,
    azure.auditlogs.properties.additional_details.key,
    azure.auditlogs.properties.additional_details.value,
    Esql.*