Query Okta System Log to Reconstruct a Suspicious Sign-In
For developers and responders who need a fast, defensible timeline of a suspicious Okta sign-in. You’ll pull the exact System Log events with the API, filter by user/IP/session, and verify the result end-to-end without guessing in the UI.
TL;DR — To reconstruct a suspicious Okta sign-in, pull the System Log via the API with a narrow time window, then pivot on
actor.alternateId,client.ipAddress, andauthenticationContext.externalSessionId. The most common reason people miss the full story is querying too broad a window in the UI and not following the session ID across MFA, policy, and app access events. Reading time: ~5 min
Goal
When you finish, you will have a timestamped sign-in timeline from Okta System Log events for one suspicious login attempt or session, including source IP, user, outcome, MFA steps, policy decisions, and any downstream app sign-on events tied to the same session.
Prerequisites
- Okta org URL, for example
https://acme.okta.comor your custom Okta domain - API token with permission to read System Log events; store it in an environment variable
curlandjqinstalled; check with:
curl --version
jq --version
- Shell with
datesupporting ISO-8601 UTC output; on Linux/macOS:
date -u +"%Y-%m-%dT%H:%M:%SZ"
- One or more of these investigation anchors:
- user login, usually email, for example
alice@example.com - suspicious source IP, for example
203.0.113.24 - approximate UTC time window, for example
2026-10-01T13:00:00Zto2026-10-01T14:00:00Z - event ID from an alert or SIEM
- user login, usually email, for example
- Optional: a writable working directory for saving raw JSON
Steps
Step 1: Export your Okta base URL and API token
export OKTA_ORG="https://acme.okta.com"
export OKTA_TOKEN="REPLACE_WITH_YOUR_TOKEN"
Success looks like your shell accepting both exports with no output.
Step 2: Confirm the API is reachable and the token works
curl -sS -D - \
-H "Authorization: SSWS $OKTA_TOKEN" \
-H "Accept: application/json" \
"$OKTA_ORG/api/v1/logs?limit=1" \
-o /tmp/okta-log-test.json
Success looks like an HTTP 200 response header and /tmp/okta-log-test.json containing a JSON array.
Example success header shape:
HTTP/2 200
content-type: application/json
link: <https://acme.okta.com/api/v1/logs?limit=1&after=...>; rel="next"
x-rate-limit-limit: 1000
x-rate-limit-remaining: 999
If the token is bad, the shape is typically:
HTTP/2 401
content-type: application/json
{"errorCode":"E0000011","errorSummary":"Invalid token provided","errorLink":"E0000011","errorId":"...","errorCauses":[]}
Step 3: Pull a narrow time window of System Log events
Use UTC and start with a 30-60 minute window around the suspicious sign-in.
START="2026-10-01T13:00:00Z"
END="2026-10-01T14:00:00Z"
OUT="okta-system-log-window.json"
curl -sS \
-H "Authorization: SSWS $OKTA_TOKEN" \
-H "Accept: application/json" \
"$OKTA_ORG/api/v1/logs?since=$START&until=$END&limit=200" \
> "$OUT"
jq 'length' "$OUT"
Success looks like jq printing a non-zero event count.
Step 4: Filter the window by user or IP
If you have the username:
USER_LOGIN="alice@example.com"
jq --arg u "$USER_LOGIN" '[.[] | select(.actor.alternateId == $u)] | sort_by(.published) | .[] | {published, eventType, outcome: .outcome.result, actor: .actor.alternateId, ip: .client.ipAddress, session: .authenticationContext.externalSessionId}' "$OUT"
If you have the source IP:
SRC_IP="203.0.113.24"
jq --arg ip "$SRC_IP" '[.[] | select(.client.ipAddress == $ip)] | sort_by(.published) | .[] | {published, eventType, outcome: .outcome.result, actor: .actor.alternateId, ip: .client.ipAddress, session: .authenticationContext.externalSessionId}' "$OUT"
Success looks like a short ordered list of events with repeated session values.
Typical output shape:
{
"published": "2026-10-01T13:22:11.123Z",
"eventType": "user.session.start",
"outcome": "SUCCESS",
"actor": "alice@example.com",
"ip": "203.0.113.24",
"session": "trs9xYz..."
}
Step 5: Pivot on the external session ID to reconstruct the whole sign-in
Take the session value from Step 4 and pull every event tied to it.
SESSION_ID="trs9xYzREPLACE"
jq --arg s "$SESSION_ID" '[.[] | select(.authenticationContext.externalSessionId == $s)] | sort_by(.published) | .[] | {published, eventType, outcome: .outcome.result, reason: .outcome.reason, actor: .actor.alternateId, ip: .client.ipAddress, target: (.target // [] | map(.displayName) | join(", "))}' "$OUT"
Success looks like a timeline that includes sign-in, MFA or policy events, and possibly app access events.
Step 6: If the timeline is incomplete, paginate instead of widening blindly
Okta returns a Link header with rel="next". Follow it until there is no next link.
FIRST_URL="$OKTA_ORG/api/v1/logs?since=$START&until=$END&limit=200"
NEXT_URL="$FIRST_URL"
: > okta-system-log-all.jsonl
while [ -n "$NEXT_URL" ]; do
HDR=$(mktemp)
BODY=$(mktemp)
curl -sS -D "$HDR" \
-H "Authorization: SSWS $OKTA_TOKEN" \
-H "Accept: application/json" \
"$NEXT_URL" > "$BODY"
jq -c '.[]' "$BODY" >> okta-system-log-all.jsonl
NEXT_URL=$(awk -F'[<>]' '/rel="next"/ {print $2}' "$HDR" | tail -1)
rm -f "$HDR" "$BODY"
done
wc -l okta-system-log-all.jsonl
Success looks like wc -l printing a larger count than the first page when your window had more than 200 events.
Step 7: Build a concise incident timeline from the paginated data
jq -s --arg u "$USER_LOGIN" --arg ip "$SRC_IP" '
add
| map(select((.actor.alternateId == $u) or (.client.ipAddress == $ip)))
| sort_by(.published)
| .[]
| {
published,
eventType,
outcome: .outcome.result,
reason: .outcome.reason,
actor: .actor.alternateId,
ip: .client.ipAddress,
session: .authenticationContext.externalSessionId,
transaction: .transaction.id,
requestId: .request.id,
target: (.target // [] | map(.displayName) | join(", ")),
userAgent: .client.userAgent.rawUserAgent
}
' okta-system-log-all.jsonl
Success looks like a sorted event stream you can paste into your incident notes with stable correlation fields: session, transaction, and requestId.
Verify it works
Run these checks against your saved data:
jq -s 'add | map(select(.actor.alternateId == "alice@example.com" and .client.ipAddress == "203.0.113.24")) | sort_by(.published) | .[] | [.published, .eventType, .outcome.result, .authenticationContext.externalSessionId] | @tsv' okta-system-log-all.jsonl
Expected result: multiple tab-separated rows in chronological order, with at least one shared externalSessionId across the suspicious sign-in events.
Then verify the session pivot returns the full chain:
jq -s 'add | map(select(.authenticationContext.externalSessionId == "trs9xYzREPLACE")) | sort_by(.published) | length' okta-system-log-all.jsonl
Expected result: a count greater than 1. If you only get one event, your time window is too narrow or you filtered on the wrong session.
Common pitfalls
Querying only the first page
Mistake: calling /api/v1/logs?...&limit=200 once and assuming you have the whole window.
Symptom: the suspicious user.session.start appears, but no MFA, policy, or app events follow even though the user clearly continued.
Fix: follow the Link header with rel="next" until it disappears, as shown in Step 6.
Using local time instead of UTC
Mistake: passing since and until values based on your laptop timezone without converting.
Symptom: zero events or a timeline shifted by several hours.
Fix: use explicit UTC timestamps ending in Z, for example 2026-10-01T13:00:00Z.
Pivoting on user only, not session
Mistake: filtering only by actor.alternateId for a busy user with multiple sign-ins in the same hour.
Symptom: mixed events from different devices or locations, making the sequence look contradictory.
Fix: grab authenticationContext.externalSessionId from one anchor event and re-query on that exact value.
Trusting the UI search over raw JSON
Mistake: relying on a broad UI search and copying what is visible on screen.
Symptom: missing fields like request.id, transaction.id, raw user agent, or exact target names needed for correlation.
Fix: save the API response and inspect with jq; keep the raw JSONL as evidence.
Not checking rate-limit headers during a large pull
Mistake: looping aggressively over many pages during a broad incident sweep.
Symptom: intermittent HTTP 429 Too Many Requests and partial data collection.
Fix: inspect x-rate-limit-* headers from Step 2 and add a short sleep 1 inside the pagination loop if you approach the limit.
Filtering on the wrong IP field
Mistake: searching for an IP in a different field name from another vendor’s schema.
Symptom: no matches even though the alert included the source IP.
Fix: filter on .client.ipAddress in the Okta System Log payload, then confirm with one raw event using jq '.[0]'.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI