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:
| Identifier | What it tracks | Use 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 token | Pinpoint 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 descHow 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 == "[email protected]" | 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 == "[email protected]" | 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 == "[email protected]" | 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 SessionId↔AADSessionId 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
- Sign-in log activity details — Microsoft LearnSessionId and token identifiers
- Audit log activities (OfficeActivity)AppAccessContext and workload operations
- Investigating a phishing report with KQLthe email side of the same investigation
- The iPhone she never ownedthe account-takeover response these feed
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.