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

Stage 0: let

let GapDays = 1;
let MinIPChanges = 2;
let MinLocChanges = 2;
let ExcludeApps = dynamic([
    "Microsoft Authentication Broker",
    "Microsoft Teams",
    "Office 365"
    ]);
let Src = union <inlined as stages below>;
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 <inlined as stages below>;

Stage 1: union

union of 2 branches

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(86400s)

Stage 7: summarize

summarize CurrentApps, CurrentFirstSeen, CurrentIPs, CurrentLastSeen, CurrentLocs, CurrentUPNs, IPCount, LocCount by UniqueTokenIdentifier, ResultType

Stage 8: join

join kind=inner (Past) on UniqueTokenIdentifier

Stage 9: extend

extend Gap

Stage 10: extend

extend GapThreshold

Stage 11: where

where Gap >= GapThreshold

Stage 12: where

where (IPCount >= 2 or LocCount >= 2)

Stage 13: extend

extend NewIPs

Stage 14: extend

extend NewLocs

Stage 15: where

where (NewIPs > 0 or NewLocs > 0)

Stage 16: extend

extend CurrentUPNCount, PastUPNCount

Stage 17: project

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

Stage 18: sort

sort by CurrentLastSeen, Gap

Stage 19: extend (6 consecutive steps)

extend FirstNewIP, NewIP, NewLoc, PastUPN, Source_Network_IPLocation, UserNames

Stage 20: project

project Alert_Category_en, Alert_Containment_en, Alert_Description_en, Alert_Name_en, Alert_Remediation_en, Alert_SubCategory_en, Alert_Time_TW, Alert_Time_UTC0, Alert_TriageStep_en, Event_Code, Event_TimeRange_End_TW, Event_TimeRange_End_UTC0, Event_TimeRange_Start_TW, Event_TimeRange_Start_UTC0, Source_Identity_FullName, Source_Network_IPAddress, Source_Network_IPLocation, Target_Identity_FullName, Target_Resource_Name, Target_Resource_Type

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