Blue Team of One field notes · security

HomeDetection EngineeringAdvanced hunting (KQL)

Advanced hunting · identity forensics

Linkable Token Identifiers: following an attacker across every M365 service

A stolen token used to read email, download files from SharePoint, and change a Teams setting used to look like three unrelated log entries. Session ID and Unique Token Identifier stitch them into one thread — so you can reconstruct exactly what happened in a single sign-in session, across every workload. Here's how to investigate with them, hunt with them, and automate response.

For years, correlating a user's actions across Microsoft 365 meant stitching together IP addresses, user agents, and timestamps — and hoping. That approach breaks exactly when it matters most: attackers share IPs via VPNs or compromised devices, and a stolen token's activity blends into the victim's own. You could see that a token was used, but not tie together everything done with it across services. Linkable token identifiers fix that.

01What the identifiers are

Two tracking tags baked into sign-ins and tokens.

Think of these as tracking tags stamped inside sign-ins and tokens that follow the request across Outlook, Teams, SharePoint, and Graph. Two matter:

IdentifierWhat it tracksUse it to…
Session ID (SID)All actions from a single sign-in session (interactive or non-interactive)Group everything that happened in one login
Unique Token Identifier (UTI)Actions performed by one specific tokenPinpoint misuse of a particular stolen token

Before, and after

Before: analysts leaned on IP + user agent + timestamp to correlate — signals that lie when an attacker shares the victim's IP, and that can't cleanly tie one token's actions together across services. After: the SID groups an entire session's activity; the UTI gives per-token traceability showing exactly which actions a given token performed across M365. That's end-to-end investigation — from the moment of sign-in to every downstream action in Exchange, Teams, SharePoint, or Graph.

02Where to find them

The identifiers surface across the logs you already query.

Linkable token identifiers now appear in Entra sign-in logs and the major workload audit logs, which is what makes cross-service correlation possible:

# the identifiers are available in:
- Microsoft Entra sign-in logs        (SigninLogs → SessionId, UniqueTokenIdentifier)
- Microsoft Exchange Online audit     (OfficeActivity → AppAccessContext.AADSessionId)
- Microsoft SharePoint Online audit   (OfficeActivity)
- Microsoft Teams audit               (OfficeActivity)
- Microsoft Graph activity logs

The join key that bridges them: the SessionId from SigninLogs equals the AADSessionId nested inside AppAccessContext in OfficeActivity. Parse it out and the two worlds connect.

03The core join — one session, every workload

Start with a session, see everything it touched.

This is the query that everything else builds on. Take a SessionId from a suspicious sign-in and pull every action performed in that session across all workloads:

// every action in one sign-in session, across all M365 workloads
let session = "<session-guid>";   // from the sign-in log
OfficeActivity
| extend AppAccessContextParsed = parse_json(AppAccessContext)
| extend AADSessionId = tostring(AppAccessContextParsed.AADSessionId)
| where AADSessionId == session
| project TimeGenerated, Operation, UserId, ClientIP, OfficeObjectId, AADSessionId
| order by TimeGenerated desc
How to read it

Every row is an action tied to that one session — a FileAccessed in SharePoint, a run of MailItemsAccessed in Exchange, a New-TransportRule or Set-Mailbox. Seeing them together reconstructs the session's story: what was read, what was exfiltrated, what was changed. The OfficeObjectId often shows the exact file or object touched.

04Investigation workflows

Four ways to turn the identifiers into answers. Pick one.

Linkable-ID investigation patterns

A · Track suspicious sign-ins

Credentials were phished — what did the session touch?

// 1. get the SessionId from the suspicious sign-in
SigninLogs
| where UserPrincipalName == "victim@contoso.com"
| where TimeGenerated between (datetime(2026-01-01) .. datetime(2026-01-03))
| project TimeGenerated, UserPrincipalName, IPAddress, AppDisplayName, SessionId

// 2. feed that SessionId into the core join (Section 03) to see
//    every Exchange / Teams / SharePoint action in that session

Take the SessionId, run the Section 03 join, and reconstruct what the attacker did — read emails, downloaded files, changed settings — all under one session.

B · Trace token misuse with the UTI

A specific stolen token was used against M365 — trace every action.

// the UniqueTokenIdentifier gives per-token granularity
SigninLogs
| where UserPrincipalName == "victim@contoso.com"
| where TimeGenerated > ago(7d)
| project TimeGenerated, IPAddress, AppDisplayName, SessionId, UniqueTokenIdentifier
| sort by TimeGenerated asc

Where the SID groups a whole session, the UniqueTokenIdentifier isolates the actions of one token — so you can see exactly which files were touched or which Teams channels were changed with that specific token, separating the attacker's token from the user's legitimate ones in the same window.

C · Enumerate active sessions

How many sessions does this compromised user have — so you can cut them.

// list every session for a user, with its IPs, apps and span
SigninLogs
| where UserPrincipalName == "victim@contoso.com"
| where TimeGenerated > ago(7d)
| summarize StartTime = min(TimeGenerated), EndTime = max(TimeGenerated),
            Apps = make_set(AppDisplayName), IPs = make_set(IPAddress)
        by SessionId
| order by StartTime desc

One row per session, showing the IPs, the apps used, and the time span. Sessions from an unexpected IP or with an unusual app mix are your revoke targets — enumerate first, then revoke to cut off unauthorized access cleanly.

D · Activity breakdown by workload

Where did a session spend its time — Exchange, SharePoint, Teams?

// count a session's actions per workload — shape of the activity
let session = "<session-guid>";
OfficeActivity
| extend AppAccessContextParsed = parse_json(AppAccessContext)
| extend AADSessionId = tostring(AppAccessContextParsed.AADSessionId)
| where AADSessionId == session
| summarize ActivityCount = count() by OfficeWorkload
| order by ActivityCount desc

A quick profile of the session: heavy Exchange counts suggest mailbox access/exfil; a spike in SharePoint/OneDrive suggests file harvesting. It tells you where to dig first before pulling the detailed per-action list.

05Hunting with session context

Bake the identifiers into detections, not just investigations.

The real power is enriching hunts with session and token context, so a hit comes with the thread already attached. Three that pay off, each tuned to cut noise:

Risky users, with the session that made them risky

AADRiskyUsers
| where IsDeleted == false
| where RiskState == "atRisk" and RiskLevel in ("high", "medium")   // real, active risk
| where UserPrincipalName !contains "svc" and UserPrincipalName !contains "noreply"  // drop service accts
| join kind=inner (
    SigninLogs
    | summarize arg_max(TimeGenerated, *) by UserPrincipalName
    | project SigninTime = TimeGenerated, SessionId, UniqueTokenIdentifier,
              UserPrincipalName, AppDisplayName, IPAddress, DeviceDetail
  ) on UserPrincipalName
| project SigninTime, UserPrincipalName, RiskLevel, RiskState,
          SessionId, UniqueTokenIdentifier, AppDisplayName, IPAddress

Ties each at-risk user directly to their most recent sign-in session — so the alert arrives with the SessionId already attached, ready to feed into the Section 03 join. Excluding svc/noreply and informational/stale risk keeps it to genuine cases.

Mailbox forwarding to an external address

let ForwardingIndicators = dynamic(["ForwardingSmtpAddress", "ForwardingAddress"]);
OfficeActivity
| where Operation == "Set-Mailbox"
| extend ParametersDynamic = todynamic(Parameters)
| mv-expand Parameter = ParametersDynamic
| extend ParamName = tostring(Parameter.Name), ParamValue = tostring(Parameter.Value)
| where ParamName in (ForwardingIndicators)
| where isnotempty(ParamValue)
| extend ForwardedEmail = tolower(trim_start("smtp:", ParamValue))
| where not(ForwardedEmail endswith "@contoso.com")   // exclude internal forwarding
| extend PerformedBy = UserId
| where PerformedBy !contains "admin" and PerformedBy !contains "svc"   // exclude admins/service accts
| project TimeGenerated, PerformedBy, ForwardedEmail, ParamName, ParamValue

Auto-forwarding to an external domain is a classic BEC exfiltration move. Excluding your own domain and pre-approved partners, plus admin/service accounts, strips the benign changes and leaves the ones worth chasing — then join to SigninLogs on the session to see who set it and from where.

Suspicious inbox-rule creation

let timeframe = 90d;
let Keywords = dynamic(["helpdesk","suspicious","fake","malicious","phishing",
                        "spam","do not click","do not open","hijacked","fatal"]);
CloudAppEvents
| where TimeGenerated >= ago(timeframe)
| where ActivityObjects has_any (Keywords) and ObjectName contains "UpdateInboxRules"
| project TimeGenerated, IPAddress, ActivityType, ObjectName,
          AccountDisplayName, Application, ActionType

Attacker inbox rules that hide replies love these keywords (a rule that files anything containing "helpdesk" or "phishing" into a folder the victim won't see). Correlate hits with the risky-users query above to prioritise rules created by accounts already flagged at-risk.

06Automating the response

Feed the session ID into a playbook that revokes on risky sign-in.

Because the risky-user hunt now emits a SessionId, you can hand it to a Sentinel playbook (Logic App) that revokes active sessions on a risky sign-in, cutting off token reuse automatically. The core flow:

# Sentinel playbook — auto-revoke on risky sign-in
Microsoft Sentinel incident (trigger)
  → Get incident
  → Entities - Get Accounts
  → For each account:
       Run query (pull the risky session's cross-workload activity)
       Revoke sign-in sessions   (invalidate tokens)
       Add comment to incident   (what was found + actioned)

Start with the core revoke and expand from there. Sensible next enhancements: pull the session's cross-workload activity logs (email access, downloads, Teams changes) into the incident; check Conditional Access enforcement outcomes (MFA / password-reset challenges) and their completion status; force password reset or disable the account in high-severity cases; and enrich the incident with a summary of user actions and responses taken. A phased approach keeps the playbook reliable while it grows toward full automated triage.

The takeaway

Linkable token identifiers turn scattered logs into one traceable thread: the SID groups a whole sign-in session, the UTI isolates a single token's actions, and the SessionIdAADSessionId join connects Entra sign-ins to every M365 workload. Use them to reconstruct a compromise end-to-end, enumerate sessions to revoke, and — once your hunts emit the session ID — hand it straight to a playbook that revokes automatically. This is what closes the gap between "a token was stolen" and "here's everything it did, and it's already cut off."

Further reading

Anonymized from real investigation work; accounts, session identifiers, IPs, and domains have been replaced with generic examples.

Comments

Questions or corrections welcome. Sign in with GitHub to join the thread.