Detection rules › Kusto

[Entra ID] Suspicious Continuous OAuth Token Usage

Status
available
Severity
high
Time window
1d
Group by
ResultType, UniqueTokenIdentifier
Source
github.com/Azure/Azure-Sentinel

Detects repeated use of the same OAuth token across different IPs or locations over time. This can indicate token theft or session abuse.

MITRE ATT&CK coverage

TacticTechniques
Credential Access

Telemetry coverage

Rules detecting the same action

These rules filter on the same operation.

Rule body

id: 67802748-435b-4f80-9f61-b9a9ac6ea15c
name: "[Entra ID] Suspicious Continuous OAuth Token Usage"
version: 1.0.0
kind: Scheduled
description: |
  Detects repeated use of the same OAuth token across different IPs or locations over time. This can indicate token theft or session abuse.
severity: High
status: Available
requiredDataConnectors:
  - connectorId: AzureActiveDirectory
    dataTypes:
      - AADNonInteractiveUserSignInLogs
      - SigninLogs
queryFrequency: 1d
queryPeriod: 1d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - CredentialAccess
relevantTechniques:
  - T1606
query: |
  // =====================
  // Low-noise Token Reuse Detector (fixed timespan calc)
  // =====================
  let GapDays = 1;
  let MinIPChanges = 2;
  let MinLocChanges = 2;
  let ExcludeApps = dynamic([
      "Microsoft Authentication Broker",
      "Microsoft Teams",
      "Office 365"
      ]);
  let Src =
      union isfuzzy=true
          AADNonInteractiveUserSignInLogs,
          SigninLogs
      | where ResultType == 0
      | where isnotempty(UniqueTokenIdentifier);
  let Past =
      Src
      | where TimeGenerated between (ago(14d) .. ago(1d))
      | summarize
          PastLastSeen = max(TimeGenerated),
          PastUPNs     = make_set(UserPrincipalName, 20),
          PastIPs      = make_set(IPAddress, 50),
          PastLocs     = make_set(tostring(Location), 50),
          PastApps     = make_set(AppDisplayName, 50)
          by UniqueTokenIdentifier;
  let Last24h =
      Src
      | where TimeGenerated >= ago(1d)
      //| where AppDisplayName !in (ExcludeApps)
      | summarize
          CurrentFirstSeen = min(TimeGenerated),
          CurrentLastSeen  = max(TimeGenerated),
          CurrentUPNs      = make_set(UserPrincipalName, 20),
          CurrentIPs       = make_set(IPAddress, 50),
          CurrentLocs      = make_set(tostring(Location), 50),
          CurrentApps      = make_set(AppDisplayName, 50),
          IPCount          = dcount(IPAddress),
          LocCount         = dcount(tostring(Location))
          by UniqueTokenIdentifier, ResultType;
  Last24h
  | join kind=inner Past on UniqueTokenIdentifier
  | extend Gap = CurrentFirstSeen - PastLastSeen
  | extend GapThreshold = totimespan(strcat(GapDays, "d"))
  | where Gap >= GapThreshold
  | where IPCount >= MinIPChanges or LocCount >= MinLocChanges
  | extend NewIPs  = set_difference(CurrentIPs, PastIPs)
  | extend NewLocs = set_difference(CurrentLocs, PastLocs)
  | where array_length(NewIPs) > 0 or array_length(NewLocs) > 0
  | extend
      CurrentUPNCount = array_length(CurrentUPNs),
      PastUPNCount = array_length(PastUPNs)
  | project
      UniqueTokenIdentifier,
      PastLastSeen,
      CurrentFirstSeen,
      CurrentLastSeen,
      Gap,
      GapThreshold,
      CurrentUPNs,
      CurrentUPNCount,
      CurrentIPs,
      IPCount,
      NewIPs,
      CurrentLocs,
      LocCount,
      NewLocs,
      CurrentApps,
      PastApps,
      PastUPNs,
      ResultType
  | order by Gap desc, CurrentLastSeen desc
  | extend UserNames = strcat_array(CurrentUPNs, ",")
  | extend NewIP = strcat_array(NewIPs, ",")
  | extend FirstNewIP = tostring(NewIPs[0])
  | extend NewLoc = strcat_array(NewLocs, ",")
  | extend PastUPN = strcat_array(PastUPNs, ",")
  | extend Source_Network_IPLocation = ""
  | project
      Alert_Time_TW = datetime_utc_to_local(CurrentLastSeen, 'Asia/Taipei'),
      Alert_Time_UTC0 = CurrentLastSeen,
          Alert_Category_en = "Entra ID",
      Alert_SubCategory_en = "Anomaly Network Access User",
      Alert_Name_en = "Abnormal Sign-in Token Reuse",
      Alert_Description_en=strcat(
                           "At Taiwan time: ",
                           format_datetime(datetime_utc_to_local(CurrentLastSeen, "Asia/Taipei"), "yyyy-MM-dd HH:mm:ss"),
                           ", detected suspected Token Reuse behavior (UniqueTokenIdentifier appeared repeatedly and the interval reached the threshold).",
                           "Token:",
                           iff(isnotempty(UniqueTokenIdentifier), UniqueTokenIdentifier, "<NoTokenId>"),
                           ", users (last 2 days): ",
                           iff(isnotempty(tostring(UserNames)), tostring(UserNames), "<NoUserNames>"),
                           ", previous last seen: ",
                           format_datetime(datetime_utc_to_local(PastLastSeen, "Asia/Taipei"), "yyyy-MM-dd HH:mm:ss"),
                           ", current first seen: ",
                           format_datetime(datetime_utc_to_local(CurrentFirstSeen, "Asia/Taipei"), "yyyy-MM-dd HH:mm:ss"),
                           ", gap: ",
                           tostring(Gap),
                           " days",
                           ", IP count: ",
                           tostring(IPCount),
                           ", current sign-in IP: ",
                           iff(isnotempty(tostring(NewIP)), tostring(NewIP), "<NoNewIP>"),
                           ", location count: ",
                           tostring(LocCount),
                           ", current sign-in location: ",
                           iff(isnotempty(tostring(NewLoc)), tostring(NewLoc), "<NoNewLoc>"),
                           "."
                       ),
      Alert_TriageStep_en=strcat(
                          " 1. Check whether this is truly token reuse. The reused IP is: ",
                          iff(isnotempty(tostring(NewIP)), tostring(NewIP), "<NoNewIPs>"),
                          ", current sign-in location: ",
                          iff(isnotempty(tostring(NewLoc)), tostring(NewLoc), "<NoNewLocs>"),
                          "  to determine whether it is an uncommon source, cross-country/cross-region, or anonymous cloud egress.",
                          " 2. Check whether multiple accounts share the same token. Current triggering account count: ",
                          tostring(CurrentUPNCount),
                          ", accounts that previously triggered this token: ",
                          tostring(PastUPN),
                          "; if the UPN count is greater than 1, prioritize suspicion of token leakage or proxy/automation abuse.",
                          " 3. Check the applications currently involved in sign-in: ",
                          iff(isnotempty(tostring(CurrentApps)), tostring(CurrentApps), "<NoCurrentApps>"),
                          "previous sign-in applications: ",
                          iff(isnotempty(tostring(PastApps)), tostring(PastApps), "<NoPastApps>"),
                          "  to determine whether they include administrative/highly sensitive applications or abnormally newly added apps."
                      ),
      Alert_Containment_en=strcat(
                           "1. Immediately revoke sign-in tokens/sessions and force re-sign-in for users (last 2 days): ",
                           iff(isnotempty(tostring(UserNames)), tostring(UserNames), "<NoUserNames>"),
                           "  to block continued access using the reused token.  ",
                           "2. If the current sign-in IP or location is abnormal, immediately block the current sign-in IP: ",
                           iff(isnotempty(tostring(NewIP)), tostring(NewIP), "<NoNewIP>"),
                           ", current sign-in location: ",
                           iff(isnotempty(tostring(NewLoc)), tostring(NewLoc), "<NoNewLoc>"),
                           ", and tighten Conditional Access (MFA/compliant device/named location).  ",
                           "3. Immediately require account security recovery: reset the password, re-register MFA, and check for suspicious devices or added authentication methods.  "
                       ),
      Alert_Remediation_en=strcat(
                           "1. Strengthen risk-based access: establish Conditional Access policies to block new IPs/new locations.  ",
                           "2. Implement token protection and endpoint governance: promote compliant devices, reduce long-lived token risk, and restrict access from unmanaged devices or legacy clients.  ",
                           "3. Inventory automation and applications: regularly check for abnormally added applications and high-privilege applications, and remove unnecessary permissions and old credentials.  ",
                           "4. Establish automated response: automatically revoke tokens, block IPs, notify users for confirmation, and create incident tickets for follow-up investigation when alerts trigger."
                       ),
              Event_Code = ResultType,
      //Event_Description = ResultDescription,
      Event_TimeRange_Start_UTC0 = CurrentFirstSeen,
      Event_TimeRange_End_UTC0 = CurrentLastSeen,
      Event_TimeRange_Start_TW = datetime_utc_to_local(CurrentFirstSeen, 'Asia/Taipei'),
      Event_TimeRange_End_TW = datetime_utc_to_local(CurrentFirstSeen, 'Asia/Taipei'),
      Source_Identity_FullName = UserNames,
      Source_Network_IPAddress = NewIP,
      Source_Network_IPLocation = Source_Network_IPLocation,
      Target_Identity_FullName = UniqueTokenIdentifier,
      //Target_Network_IPAddress = UniqueTokenIdentifier,
      //Target_Resource_ID = "",
      Target_Resource_Name = "Microsoft Entra ID",
      Target_Resource_Type = "Microsoft Entra ID"
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: Name
        columnName: Source_Identity_FullName
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: Source_Network_IPAddress
alertDetailsOverride:
  alertDynamicProperties: []

Stages and Predicates

Parameters

let GapDays = 1;
let MinIPChanges = 2;
let MinLocChanges = 2;

let Src and let Last24h are inlined into the numbered stages below.

Let binding: ExcludeApps

let ExcludeApps = dynamic([
    "Microsoft Authentication Broker",
    "Microsoft Teams",
    "Office 365"
    ]);

Let binding: Past used in Stage 8

let Past = Src
    | where TimeGenerated between (ago(14d) .. ago(1d))
    | summarize
        PastLastSeen = max(TimeGenerated),
        PastUPNs     = make_set(UserPrincipalName, 20),
        PastIPs      = make_set(IPAddress, 50),
        PastLocs     = make_set(tostring(Location), 50),
        PastApps     = make_set(AppDisplayName, 50)
        by UniqueTokenIdentifier;

Stages 1 to 7 define let Last24h (the rule's main pipeline source); stages 8 to 20 run on it.

Stage 1: union

union isfuzzy=true AADNonInteractiveUserSignInLogs, SigninLogs

Stage 2: source

AADNonInteractiveUserSignInLogs

Stage 3: source

SigninLogs

Stage 4: where

| where ResultType == 0

Stage 5: where

| where isnotempty(UniqueTokenIdentifier)

Stage 6: where

| where TimeGenerated >= ago(1d)

Stage 7: summarize

| summarize
        CurrentFirstSeen = min(TimeGenerated),
        CurrentLastSeen  = max(TimeGenerated),
        CurrentUPNs      = make_set(UserPrincipalName, 20),
        CurrentIPs       = make_set(IPAddress, 50),
        CurrentLocs      = make_set(tostring(Location), 50),
        CurrentApps      = make_set(AppDisplayName, 50),
        IPCount          = dcount(IPAddress),
        LocCount         = dcount(tostring(Location))
        by UniqueTokenIdentifier, ResultType

Stage 8: join

Last24h
| join kind=inner Past on UniqueTokenIdentifier

Stage 9: extend

| extend Gap = CurrentFirstSeen - PastLastSeen

Stage 10: extend

| extend GapThreshold = totimespan(strcat(GapDays, "d"))

Stage 11: where

| where Gap >= GapThreshold

Stage 12: where

| where IPCount >= MinIPChanges or LocCount >= MinLocChanges

Stage 13: extend

| extend NewIPs  = set_difference(CurrentIPs, PastIPs)

Stage 14: extend

| extend NewLocs = set_difference(CurrentLocs, PastLocs)

Stage 15: where

| where array_length(NewIPs) > 0 or array_length(NewLocs) > 0

Stage 16: extend

| extend
    CurrentUPNCount = array_length(CurrentUPNs),
    PastUPNCount = array_length(PastUPNs)

Stage 17: project

| project
    UniqueTokenIdentifier,
    PastLastSeen,
    CurrentFirstSeen,
    CurrentLastSeen,
    Gap,
    GapThreshold,
    CurrentUPNs,
    CurrentUPNCount,
    CurrentIPs,
    IPCount,
    NewIPs,
    CurrentLocs,
    LocCount,
    NewLocs,
    CurrentApps,
    PastApps,
    PastUPNs,
    ResultType

Stage 18: sort

| order by Gap desc, CurrentLastSeen desc

Stage 19: extend (6 consecutive steps)

| extend UserNames = strcat_array(CurrentUPNs, ",")
| extend NewIP = strcat_array(NewIPs, ",")
| extend FirstNewIP = tostring(NewIPs[0])
| extend NewLoc = strcat_array(NewLocs, ",")
| extend PastUPN = strcat_array(PastUPNs, ",")
| extend Source_Network_IPLocation = ""

Stage 20: project

| project
    Alert_Time_TW = datetime_utc_to_local(CurrentLastSeen, 'Asia/Taipei'),
    Alert_Time_UTC0 = CurrentLastSeen,
        Alert_Category_en = "Entra ID",
    Alert_SubCategory_en = "Anomaly Network Access User",
    Alert_Name_en = "Abnormal Sign-in Token Reuse",
    Alert_Description_en=strcat(
                         "At Taiwan time: ",
                         format_datetime(datetime_utc_to_local(CurrentLastSeen, "Asia/Taipei"), "yyyy-MM-dd HH:mm:ss"),
                         ", detected suspected Token Reuse behavior (UniqueTokenIdentifier appeared repeatedly and the interval reached the threshold).",
                         "Token:",
                         iff(isnotempty(UniqueTokenIdentifier), UniqueTokenIdentifier, "<NoTokenId>"),
                         ", users (last 2 days): ",
                         iff(isnotempty(tostring(UserNames)), tostring(UserNames), "<NoUserNames>"),
                         ", previous last seen: ",
                         format_datetime(datetime_utc_to_local(PastLastSeen, "Asia/Taipei"), "yyyy-MM-dd HH:mm:ss"),
                         ", current first seen: ",
                         format_datetime(datetime_utc_to_local(CurrentFirstSeen, "Asia/Taipei"), "yyyy-MM-dd HH:mm:ss"),
                         ", gap: ",
                         tostring(Gap),
                         " days",
                         ", IP count: ",
                         tostring(IPCount),
                         ", current sign-in IP: ",
                         iff(isnotempty(tostring(NewIP)), tostring(NewIP), "<NoNewIP>"),
                         ", location count: ",
                         tostring(LocCount),
                         ", current sign-in location: ",
                         iff(isnotempty(tostring(NewLoc)), tostring(NewLoc), "<NoNewLoc>"),
                         "."
                     ),
    Alert_TriageStep_en=strcat(
                        " 1. Check whether this is truly token reuse. The reused IP is: ",
                        iff(isnotempty(tostring(NewIP)), tostring(NewIP), "<NoNewIPs>"),
                        ", current sign-in location: ",
                        iff(isnotempty(tostring(NewLoc)), tostring(NewLoc), "<NoNewLocs>"),
                        "  to determine whether it is an uncommon source, cross-country/cross-region, or anonymous cloud egress.",
                        " 2. Check whether multiple accounts share the same token. Current triggering account count: ",
                        tostring(CurrentUPNCount),
                        ", accounts that previously triggered this token: ",
                        tostring(PastUPN),
                        "; if the UPN count is greater than 1, prioritize suspicion of token leakage or proxy/automation abuse.",
                        " 3. Check the applications currently involved in sign-in: ",
                        iff(isnotempty(tostring(CurrentApps)), tostring(CurrentApps), "<NoCurrentApps>"),
                        "previous sign-in applications: ",
                        iff(isnotempty(tostring(PastApps)), tostring(PastApps), "<NoPastApps>"),
                        "  to determine whether they include administrative/highly sensitive applications or abnormally newly added apps."
                    ),
    Alert_Containment_en=strcat(
                         "1. Immediately revoke sign-in tokens/sessions and force re-sign-in for users (last 2 days): ",
                         iff(isnotempty(tostring(UserNames)), tostring(UserNames), "<NoUserNames>"),
                         "  to block continued access using the reused token.  ",
                         "2. If the current sign-in IP or location is abnormal, immediately block the current sign-in IP: ",
                         iff(isnotempty(tostring(NewIP)), tostring(NewIP), "<NoNewIP>"),
                         ", current sign-in location: ",
                         iff(isnotempty(tostring(NewLoc)), tostring(NewLoc), "<NoNewLoc>"),
                         ", and tighten Conditional Access (MFA/compliant device/named location).  ",
                         "3. Immediately require account security recovery: reset the password, re-register MFA, and check for suspicious devices or added authentication methods.  "
                     ),
    Alert_Remediation_en=strcat(
                         "1. Strengthen risk-based access: establish Conditional Access policies to block new IPs/new locations.  ",
                         "2. Implement token protection and endpoint governance: promote compliant devices, reduce long-lived token risk, and restrict access from unmanaged devices or legacy clients.  ",
                         "3. Inventory automation and applications: regularly check for abnormally added applications and high-privilege applications, and remove unnecessary permissions and old credentials.  ",
                         "4. Establish automated response: automatically revoke tokens, block IPs, notify users for confirmation, and create incident tickets for follow-up investigation when alerts trigger."
                     ),
            Event_Code = ResultType,
    Event_TimeRange_Start_UTC0 = CurrentFirstSeen,
    Event_TimeRange_End_UTC0 = CurrentLastSeen,
    Event_TimeRange_Start_TW = datetime_utc_to_local(CurrentFirstSeen, 'Asia/Taipei'),
    Event_TimeRange_End_TW = datetime_utc_to_local(CurrentFirstSeen, 'Asia/Taipei'),
    Source_Identity_FullName = UserNames,
    Source_Network_IPAddress = NewIP,
    Source_Network_IPLocation = Source_Network_IPLocation,
    Target_Identity_FullName = UniqueTokenIdentifier,
    Target_Resource_Name = "Microsoft Entra ID",
    Target_Resource_Type = "Microsoft Entra ID"

Indicators

These rows show field, operator, and value matches.

Output fields

These fields are emitted when the rule matches.

FieldSource
Alert_Category_enproject
Alert_Containment_enproject
Alert_Description_enproject
Alert_Name_enproject
Alert_Remediation_enproject
Alert_SubCategory_enproject
Alert_Time_TWproject
Alert_Time_UTC0project
Alert_TriageStep_enproject
Event_Codeproject
Event_TimeRange_End_TWproject
Event_TimeRange_End_UTC0project
Event_TimeRange_Start_TWproject
Event_TimeRange_Start_UTC0project
Source_Identity_FullNameproject
Source_Network_IPAddressproject
Source_Network_IPLocationproject
Target_Identity_FullNameproject
Target_Resource_Nameproject
Target_Resource_Typeproject