soc operations

Hunting SaaS SSO Token Abuse

Detect SSO and OAuth token replay and SaaS lateral movement.

detection-engineeringentra-idoauthoktapass-the-cookiessothreat-huntingtoken-theft
Install this skill
npx skills add mukul975/Anthropic-Cybersecurity-Skills
Framework mappings

Overview

Adversaries increasingly bypass MFA not by defeating it but by stealing the artifacts issued after a successful authentication — session cookies, OAuth access/refresh tokens, and Primary Refresh Tokens (PRTs). With a stolen token an attacker replays the existing session ("pass-the-cookie" / token replay), inheriting the victim's authenticated state across federated SaaS without ever prompting for credentials or MFA. Mandiant's M-Trends reporting and Microsoft/Okta incident data both highlight token theft as a dominant cloud lateral-movement technique, mapped to MITRE ATT&CK T1550.001 Use Alternate Authentication Material: Application Access Token.

Detection relies on correlating identity telemetry rather than watching for failed logins. In Microsoft Entra ID the key tables are SigninLogs (interactive), AADNonInteractiveUserSignInLogs (where replayed cookies/refresh tokens commonly surface), and AADServicePrincipalSignInLogs. Entra now exposes linkable identifiersSessionId and UniqueTokenIdentifier — that let a hunter stitch every artifact derived from one root authentication event together and spot a single session being used from multiple IPs, ASNs, or device fingerprints. In Okta the System Log carries authentication.sso, policy.evaluate_sign_on, and user.session.start events with a deviceToken/session context; the same session token appearing from divergent IPs/user-agents is the tell. Okta Identity Threat Protection (ITP) can natively flag "suspected session hijacking."

This skill provides a hypothesis-driven hunt: baseline normal session behavior, then look for impossible travel within a single session, refresh-token reuse, token use from anomalous infrastructure (hosting/VPS ASNs), and SaaS access patterns inconsistent with the user's device. Source: MITRE ATT&CK T1550.001; Microsoft Entra ID sign-in log documentation; Okta System Log reference; Mandiant M-Trends.

When to Use

  • Threat hunting for MFA-bypass via stolen tokens/cookies across Entra ID and SaaS
  • Investigating an alert for impossible travel, anomalous OAuth grant, or token reuse
  • Validating detection coverage for T1550.001 after a phishing/AiTM incident
  • Building Sentinel/Splunk/Okta detections for session-token replay
  • Post-incident hunting to scope SaaS lateral movement from a compromised identity

Prerequisites

  • Entra ID sign-in logs flowing to a queryable store (Microsoft Sentinel / Log Analytics):
    # Confirm the diagnostic settings export SigninLogs + non-interactive logs to a workspace
    az monitor diagnostic-settings list --resource \
      /providers/Microsoft.aadiam/diagnosticSettings -o table
  • Okta System Log access via API or SIEM ingestion:
    curl -s -H "Authorization: SSWS $OKTA_API_TOKEN" \
      "https://<org>.okta.com/api/v1/logs?filter=eventType eq \"user.session.start\"&since=2026-06-01T00:00:00Z"
  • An IP enrichment source (GeoIP + ASN/hosting-provider classification)
  • Read access to the SIEM (KQL for Sentinel, SPL for Splunk)
  • Python 3.9+ for the helper script (requests for the Okta API)

Objectives

  • Baseline normal per-user session behavior (IPs, ASNs, devices, SaaS apps)
  • Correlate Entra sign-in artifacts by SessionId / UniqueTokenIdentifier
  • Detect a single session used from multiple IPs/ASNs (token replay)
  • Detect impossible travel within one authenticated session
  • Detect refresh-token reuse and anomalous OAuth grants
  • Hunt Okta System Log for reused session tokens across contexts
  • Produce findings and feed confirmed patterns into standing detections

MITRE ATT&CK Mapping

ID Name Use in this skill
T1550.001 Use Alternate Authentication Material: Application Access Token Core technique — replaying stolen OAuth tokens/cookies
T1539 Steal Web Session Cookie The cookie theft that precedes pass-the-cookie replay
T1528 Steal Application Access Token Acquisition of OAuth tokens via phishing/illicit consent
T1078.004 Valid Accounts: Cloud Accounts Replayed tokens grant valid-account access to SaaS
T1098.001 Account Manipulation: Additional Cloud Credentials Follow-on persistence after token abuse

Workflow

1. Correlate Entra sign-in artifacts by session

Stitch interactive, non-interactive, and SP sign-ins for one session to see the full chain.

union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(7d)
| where isnotempty(SessionId)
| summarize IPs=make_set(IPAddress), Apps=make_set(AppDisplayName),
            Locations=make_set(tostring(LocationDetails.countryOrRegion)),
            Count=count() by SessionId, UserPrincipalName
| where array_length(IPs) > 1

2. Detect a single session used from multiple ASNs (token replay)

AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(24h)
| extend ASN = tostring(parse_json(tostring(NetworkLocationDetails))[0].networkType)
| summarize distinctIPs = dcount(IPAddress),
            ipset = make_set(IPAddress) by SessionId, UserPrincipalName
| where distinctIPs >= 2

3. Detect impossible travel within one authenticated session

SigninLogs
| where TimeGenerated > ago(7d)
| project TimeGenerated, UserPrincipalName, IPAddress,
          City=tostring(LocationDetails.city),
          Country=tostring(LocationDetails.countryOrRegion), SessionId
| order by UserPrincipalName, TimeGenerated asc
| serialize
| extend prevCountry = prev(Country), prevTime = prev(TimeGenerated),
         prevUser = prev(UserPrincipalName)
| where UserPrincipalName == prevUser and Country != prevCountry
        and datetime_diff('minute', TimeGenerated, prevTime) < 60

4. Detect token use from hosting/VPS infrastructure

Replayed tokens are frequently used from datacenter ASNs, unlike the user's residential/corporate ranges.

AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(24h)
| where ResultType == 0
| extend asnOrg = tostring(parse_json(tostring(AutonomousSystemNumber)))
| where IPAddress in (toscalar(externaldata(ip:string)["<hosting-asn-iplist>"]))
| project TimeGenerated, UserPrincipalName, AppDisplayName, IPAddress

5. Hunt anomalous OAuth grants / illicit consent (token theft precursor)

AuditLogs
| where TimeGenerated > ago(30d)
| where OperationName in ("Consent to application", "Add OAuth2PermissionGrant",
                          "Add delegated permission grant")
| extend app = tostring(TargetResources[0].displayName)
| project TimeGenerated, InitiatedBy, app, Result

6. Hunt the Okta System Log for reused session tokens

A single Okta session (deviceToken) used from divergent IPs/clients indicates hijack.

curl -s -H "Authorization: SSWS $OKTA_API_TOKEN" \
  "https://<org>.okta.com/api/v1/logs?filter=eventType eq \"policy.evaluate_sign_on\"&since=2026-06-15T00:00:00Z" \
  | jq -r '.[] | [.authenticationContext.externalSessionId, .client.ipAddress, .client.userAgent.rawUserAgent] | @tsv' \
  | sort | uniq -c | sort -rn

7. Splunk equivalent for Okta session reuse

index=okta eventType="policy.evaluate_sign_on"
| stats dc(client.ipAddress) as ip_count
        values(client.ipAddress) as ips
        values(client.userAgent.rawUserAgent) as agents
        by authenticationContext.externalSessionId actor.alternateId
| where ip_count > 1

8. Triage and respond

For confirmed token abuse, revoke sessions and rotate, then promote the hunt to a rule.

# Revoke all refresh tokens / sessions for the user in Entra
az rest --method POST \
  --url "https://graph.microsoft.com/v1.0/users/<userId>/revokeSignInSessions"

See scripts/agent.py to pull Okta logs and flag reused session tokens automatically.

Tools and Resources

Resource Purpose Link
MITRE ATT&CK T1550.001 Technique reference https://attack.mitre.org/techniques/T1550/001/
Entra sign-in logs schema KQL hunting field reference https://learn.microsoft.com/en-us/entra/identity/monitoring-health/reference-azure-monitor-sign-ins-log-schema
Azure-Sentinel hunting repo Community KQL detections https://github.com/Azure/Azure-Sentinel
Okta System Log API Event hunting source https://developer.okta.com/docs/reference/api/system-log/
Mandiant M-Trends Token-theft threat landscape https://www.mandiant.com/m-trends
AzureAD-Attack-Defense PRT/token replay detection guidance https://github.com/Cloud-Architekt/AzureAD-Attack-Defense

Preventive Controls to Recommend

Detection should pair with controls that make stolen tokens far less useful:

  • Entra Conditional Access "token protection" binds the sign-in session to the device, so an exfiltrated cookie/PRT cannot be replayed off-device.
  • Continuous Access Evaluation (CAE) revokes access in near-real-time on risk events instead of waiting for token expiry.
  • Phishing-resistant MFA (FIDO2/passkeys) blocks the AiTM proxy phishing that harvests tokens in the first place.
  • Short token lifetimes + refresh-token rotation shrink the replay window and turn refresh-token reuse into an unambiguous compromise signal.
  • Okta Identity Threat Protection (ITP) flags suspected session hijacking natively.

False-Positive Tuning

Benign cause Tuning
Corporate VPN/proxy egress (many users, few IPs) Allowlist known egress IPs/ASNs
Mobile carrier IP rotation Widen impossible-travel time/distance thresholds
Legitimate multi-device users Correlate device IDs, not just IPs
Backend/API calls within one session Exclude expected service principals

Key Indicators

Indicator Signal
One SessionId across multiple IPs/ASNs Token/cookie replay
Non-interactive sign-in from new datacenter IP Replayed refresh token
Impossible travel within < 1h Concurrent session use
Refresh-token reuse after rotation Strong compromise signal
New OAuth consent to unfamiliar app Illicit-consent token theft
Okta session token from divergent user-agents Session hijack

Validation Criteria

  • Entra SigninLogs and AADNonInteractiveUserSignInLogs queryable
  • Okta System Log accessible via API or SIEM
  • Per-session correlation by SessionId produces results
  • Multi-IP / multi-ASN single-session query implemented
  • Impossible-travel-within-session query implemented
  • Anomalous OAuth consent hunt implemented
  • Okta reused-session-token hunt implemented
  • Confirmed findings triaged and sessions revoked
  • Effective queries promoted to standing detection rules
  • False-positive baseline (VPN/proxy egress) documented
Source materials

References and resources

Everything below is rendered for inspection. Script files are read-only and never run.

References 2

api-reference.md2.2 KB

SSO Token-Abuse Hunting — Data Source Reference

Microsoft Entra ID (Sentinel / Log Analytics) Tables

Table Description
SigninLogs Interactive user sign-ins
AADNonInteractiveUserSignInLogs Non-interactive sign-ins; common surface for replayed cookies/refresh tokens
AADServicePrincipalSignInLogs Service-principal sign-ins
AuditLogs Directory changes incl. OAuth consent / permission grants
AADManagedIdentitySignInLogs Managed-identity authentications

Key correlation fields

Field Meaning
SessionId Linkable identifier joining all artifacts from one root auth event
UniqueTokenIdentifier Per-token identifier; correlate issuance to usage
IPAddress / LocationDetails Source IP and GeoIP for impossible-travel/ASN checks
AppDisplayName / ResourceDisplayName Which SaaS app/resource the token accessed
ConditionalAccessStatus Whether CA applied (notApplied can indicate replay)
ResultType 0 = success

Okta System Log API

GET https://<org>.okta.com/api/v1/logs with header Authorization: SSWS <token>.

Parameter Description
filter SCIM filter, e.g. eventType eq "policy.evaluate_sign_on"
since / until ISO-8601 time bounds
q Free-text search
limit Page size

Key event types and fields

Item Meaning
user.session.start New session created
policy.evaluate_sign_on Sign-on policy evaluation (per-access)
authentication.sso SSO into a downstream app
authenticationContext.externalSessionId Session identifier for reuse detection
client.ipAddress / client.userAgent.rawUserAgent Source context for divergence checks
actor.alternateId The user

Response / Remediation

Action Command/API
Revoke Entra sessions POST https://graph.microsoft.com/v1.0/users/{id}/revokeSignInSessions
Clear Okta user sessions DELETE https://<org>.okta.com/api/v1/users/{id}/sessions
Enforce token protection Entra Conditional Access "Require token protection for sign-in sessions"
standards.md1.4 KB

Standards and Framework Mapping

NIST Cybersecurity Framework 2.0

ID Name Rationale
DE.CM-01 Networks and network services are monitored to find potentially adverse events Hunting SSO/OAuth token replay across identity logs is continuous monitoring of authentication services for adverse events.

MITRE ATT&CK (Enterprise / Cloud)

ID Name Rationale
T1550.001 Use Alternate Authentication Material: Application Access Token Core hunted technique — replaying stolen OAuth tokens/cookies.
T1539 Steal Web Session Cookie The cookie theft that enables pass-the-cookie.
T1528 Steal Application Access Token Token acquisition via phishing/illicit consent.
T1078.004 Valid Accounts: Cloud Accounts Replayed tokens provide valid-account SaaS access.
T1098.001 Account Manipulation: Additional Cloud Credentials Follow-on persistence after token abuse.

Supporting References

Scripts 1

agent.py5.2 KB
Display-only source. This catalog never executes bundled scripts.
#!/usr/bin/env python3
"""
SaaS SSO token-abuse hunter (Okta System Log).

Pulls Okta System Log events over the API and flags session tokens
(externalSessionId) that are observed from multiple source IPs or user-agents
within the window — a strong indicator of session-cookie/token replay
(MITRE ATT&CK T1550.001 / pass-the-cookie).

Defensive tool. Requires a read-only Okta API token.

Examples:
  export OKTA_ORG=example.okta.com
  export OKTA_API_TOKEN=00xxxx
  python agent.py --since 2026-06-15T00:00:00Z --min-ips 2
  python agent.py --since 2026-06-15T00:00:00Z --event-type user.session.start --json out.json
"""
import argparse
import json
import os
import sys
from collections import defaultdict

try:
    import requests
except ImportError:
    sys.exit("error: 'requests' required. Install with: pip install requests")


def fetch_logs(org, token, since, event_type, page_limit):
    url = f"https://{org}/api/v1/logs"
    headers = {"Authorization": f"SSWS {token}", "Accept": "application/json"}
    params = {"since": since, "limit": 1000}
    if event_type:
        params["filter"] = f'eventType eq "{event_type}"'

    events = []
    pages = 0
    while url and pages < page_limit:
        try:
            resp = requests.get(url, headers=headers, params=params, timeout=60)
        except requests.RequestException as exc:
            sys.exit(f"error: request failed: {exc}")
        if resp.status_code == 401:
            sys.exit("error: 401 Unauthorized — check OKTA_API_TOKEN")
        if resp.status_code == 429:
            sys.exit("error: 429 rate limited — retry later")
        if resp.status_code != 200:
            sys.exit(f"error: Okta API returned {resp.status_code}: {resp.text[:200]}")
        batch = resp.json()
        if not batch:
            break
        events.extend(batch)
        params = None  # subsequent pages use the full 'next' link
        url = None
        link = resp.headers.get("link", "")
        for part in link.split(","):
            if 'rel="next"' in part:
                url = part[part.find("<") + 1:part.find(">")]
        pages += 1
    return events


def analyze(events, min_ips):
    sessions = defaultdict(lambda: {"ips": set(), "agents": set(), "users": set(), "count": 0})
    for ev in events:
        ctx = ev.get("authenticationContext") or {}
        sid = ctx.get("externalSessionId")
        if not sid or sid == "unknown":
            continue
        client = ev.get("client") or {}
        ua = (client.get("userAgent") or {}).get("rawUserAgent")
        actor = ev.get("actor") or {}
        rec = sessions[sid]
        if client.get("ipAddress"):
            rec["ips"].add(client["ipAddress"])
        if ua:
            rec["agents"].add(ua)
        if actor.get("alternateId"):
            rec["users"].add(actor["alternateId"])
        rec["count"] += 1

    findings = []
    for sid, rec in sessions.items():
        if len(rec["ips"]) >= min_ips or len(rec["agents"]) >= 2:
            findings.append({
                "session_id": sid,
                "users": sorted(rec["users"]),
                "distinct_ips": sorted(rec["ips"]),
                "distinct_user_agents": sorted(rec["agents"]),
                "event_count": rec["count"],
            })
    findings.sort(key=lambda f: len(f["distinct_ips"]), reverse=True)
    return findings


def main():
    p = argparse.ArgumentParser(description="Okta SSO token-abuse hunter")
    p.add_argument("--org", default=os.environ.get("OKTA_ORG"),
                   help="Okta org domain, e.g. example.okta.com (or OKTA_ORG)")
    p.add_argument("--token", default=os.environ.get("OKTA_API_TOKEN"),
                   help="Okta API token (or OKTA_API_TOKEN)")
    p.add_argument("--since", required=True, help="ISO-8601 start time")
    p.add_argument("--event-type", default="policy.evaluate_sign_on",
                   help="Okta eventType to query")
    p.add_argument("--min-ips", type=int, default=2,
                   help="flag sessions seen from >= this many IPs")
    p.add_argument("--max-pages", type=int, default=20, help="max API pages to pull")
    p.add_argument("--json", metavar="FILE", help="write findings JSON to file")
    args = p.parse_args()

    if not args.org or not args.token:
        sys.exit("error: provide --org/--token or set OKTA_ORG/OKTA_API_TOKEN")

    print(f"[*] fetching Okta '{args.event_type}' events since {args.since} ...")
    events = fetch_logs(args.org, args.token, args.since, args.event_type, args.max_pages)
    print(f"[+] retrieved {len(events)} events")

    findings = analyze(events, args.min_ips)
    print(f"[+] {len(findings)} suspicious session(s) (multi-IP / multi-UA)\n")
    for f in findings:
        print(f"  session {f['session_id']} user={','.join(f['users'])}")
        print(f"    IPs ({len(f['distinct_ips'])}): {', '.join(f['distinct_ips'])}")
        print(f"    UAs ({len(f['distinct_user_agents'])}): {len(f['distinct_user_agents'])} distinct")
        print(f"    events: {f['event_count']}")

    if args.json:
        with open(args.json, "w", encoding="utf-8") as fh:
            json.dump(findings, fh, indent=2)
        print(f"\n[+] wrote {args.json}")

    sys.exit(1 if findings else 0)


if __name__ == "__main__":
    main()
Keep exploring