Query Okta System Log to Reconstruct a Suspicious Sign-In
This guide is for developers and responders who need to rebuild the timeline of a suspicious Okta sign-in from raw System Log events. You’ll finish with exact API calls, filters, and `jq` commands that identify the actor, IP, client, MFA outcome, and session lifecycle without guessing through the UI.
TL;DR — To reconstruct a suspicious Okta sign-in, pull the System Log through the Okta API with a bounded time window, then filter on the user, IP, and request/session fields to stitch together authentication, MFA, and session events. The most common reason people miss the story is querying too broad a window or only looking for
user.session.startand ignoringpolicy.evaluate_sign_on,user.authentication.*, and MFA events tied by the same actor and client context. Reading time: ~5 min
Goal
When you finish, you will have a repeatable command-line workflow that exports the relevant Okta System Log events for one suspicious sign-in and shows the timeline, source IP, user agent, authentication result, MFA steps, and session outcome in a form you can paste into an incident ticket.
Prerequisites
- Okta org URL, for example
https://acme.okta.comor your custom Okta domain - Okta API token with permission to read the System Log; test it before you start
curl7.68+ — check withcurl --versionjq1.6+ — check withjq --version- UTC timestamps for the suspected window in ISO 8601 format, for example
2026-08-10T13:00:00Z - At least one pivot value from the alert: username, Okta user ID, source IP, session ID, or approximate sign-in time
- Shell with environment variables support (
bash,zsh, or similar)
Steps
Step 1: Set your Okta org, token, and investigation window
export OKTA_ORG="https://acme.okta.com"
export OKTA_TOKEN="REPLACE_WITH_YOUR_API_TOKEN"
export START="2026-08-10T13:00:00Z"
export END="2026-08-10T14:00:00Z"
export USER_LOGIN="alice@example.com"
Success looks like your shell accepts the variables with no output.
Step 2: Verify the token can read the System Log
curl -sS -D /tmp/okta_headers.txt \
-H "Authorization: SSWS $OKTA_TOKEN" \
-H "Accept: application/json" \
"$OKTA_ORG/api/v1/logs?limit=1" | jq 'length'
sed -n '1,12p' /tmp/okta_headers.txt
Success looks like 1 or 0 from jq, and an HTTP status like this in the headers:
HTTP/2 200
content-type: application/json
link: <https://acme.okta.com/api/v1/logs?limit=1&after=...>; rel="next"
Step 3: Pull the bounded event set for the suspicious window
Use since and until first. Keep the window tight; one hour is usually enough to start.
curl -sS \
-H "Authorization: SSWS $OKTA_TOKEN" \
-H "Accept: application/json" \
--get "$OKTA_ORG/api/v1/logs" \
--data-urlencode "since=$START" \
--data-urlencode "until=$END" \
--data-urlencode "limit=1000" > okta-window.json
jq 'length' okta-window.json
Success looks like a nonzero event count, for example:
42
Step 4: Filter the window to the user and print the sign-in timeline
Start with the login value if you have it. This keeps all event types so you do not miss MFA or policy decisions.
jq -r --arg login "$USER_LOGIN" '
.[]
| select(.actor.alternateId == $login or .target[]?.alternateId == $login)
| [
.published,
.eventType,
.outcome.result,
(.client.ipAddress // "-"),
(.client.userAgent.rawUserAgent // "-"),
(.authenticationContext.externalSessionId // "-"),
(.transaction.id // "-")
]
| @tsv' okta-window.json | sort
Success looks like tab-separated rows ordered by time, for example:
2026-08-10T13:21:04.123Z policy.evaluate_sign_on ALLOW 203.0.113.24 Mozilla/5.0 ... trs9Yx... Yk7a...
2026-08-10T13:21:05.010Z user.authentication.verify SUCCESS 203.0.113.24 Mozilla/5.0 ... trs9Yx... Yk7a...
2026-08-10T13:21:08.442Z user.mfa.okta_verify.verify SUCCESS 203.0.113.24 Mozilla/5.0 ... trs9Yx... Yk7a...
2026-08-10T13:21:09.001Z user.session.start SUCCESS 203.0.113.24 Mozilla/5.0 ... trs9Yx... Yk7a...
Step 5: Pivot on the source IP to see what else happened from that client
If the alert started from an IP, or you found one in Step 4, pivot on it and include geolocation and request URI where present.
export SRC_IP="203.0.113.24"
jq -r --arg ip "$SRC_IP" '
.[]
| select(.client.ipAddress == $ip)
| [
.published,
.eventType,
(.actor.alternateId // .actor.displayName // "-"),
.outcome.result,
(.client.geographicalContext.city // "-"),
(.client.geographicalContext.country // "-"),
(.request.ipChain[0].source // "-"),
(.debugContext.debugData.requestUri // "-")
]
| @tsv' okta-window.json | sort
Success looks like all events from that IP in the window, including unrelated users if the IP was shared.
Step 6: Group the suspicious sign-in by session and transaction IDs
For a clean reconstruction, use the externalSessionId and transaction.id from Step 4. These are the best pivots when multiple sign-ins happen close together.
export SESSION_ID="trs9YxREPLACE"
export TXN_ID="Yk7aREPLACE"
jq -r --arg sid "$SESSION_ID" --arg tid "$TXN_ID" '
.[]
| select(.authenticationContext.externalSessionId == $sid or .transaction.id == $tid)
| {
published,
eventType,
outcome: .outcome.result,
reason: (.outcome.reason // "-"),
actor: (.actor.alternateId // .actor.displayName // "-"),
ip: (.client.ipAddress // "-"),
userAgent: (.client.userAgent.rawUserAgent // "-"),
sessionId: (.authenticationContext.externalSessionId // "-"),
transactionId: (.transaction.id // "-"),
requestUri: (.debugContext.debugData.requestUri // "-")
}' okta-window.json
Success looks like a compact JSON stream containing only the events for the suspicious sign-in attempt.
Step 7: Export a responder-friendly CSV timeline
This gives you something easy to attach to a case or share internally.
jq -r --arg sid "$SESSION_ID" --arg tid "$TXN_ID" '
["published","eventType","result","reason","actor","ip","userAgent","sessionId","transactionId"],
(.[]
| select(.authenticationContext.externalSessionId == $sid or .transaction.id == $tid)
| [
.published,
.eventType,
.outcome.result,
(.outcome.reason // ""),
(.actor.alternateId // .actor.displayName // ""),
(.client.ipAddress // ""),
(.client.userAgent.rawUserAgent // ""),
(.authenticationContext.externalSessionId // ""),
(.transaction.id // "")
])
| @csv' okta-window.json > suspicious-signin.csv
head -5 suspicious-signin.csv
Success looks like a CSV header plus rows you can open directly:
"published","eventType","result","reason","actor","ip","userAgent","sessionId","transactionId"
"2026-08-10T13:21:04.123Z","policy.evaluate_sign_on","ALLOW","","alice@example.com","203.0.113.24","Mozilla/5.0 ...","trs9Yx...","Yk7a..."
Verify it works
Run these checks against the exported timeline and the raw event set:
jq -r --arg sid "$SESSION_ID" --arg tid "$TXN_ID" '
.[] | select(.authenticationContext.externalSessionId == $sid or .transaction.id == $tid) | .eventType' okta-window.json | sort -u
csvcut -n suspicious-signin.csv 2>/dev/null || head -1 suspicious-signin.csv
wc -l suspicious-signin.csv
Expected result:
- The unique event list includes the events that explain the sign-in path, commonly some combination of
policy.evaluate_sign_on,user.authentication.verify,user.mfa.*, anduser.session.startor a failure event. - The CSV has a header row and at least one event row.
- The IP, user agent, and timestamps in the CSV match the original alert or the user report.
If you need to prove the API query itself is correct, this should return HTTP 200:
curl -I -H "Authorization: SSWS $OKTA_TOKEN" "$OKTA_ORG/api/v1/logs?limit=1"
Expected shape:
HTTP/2 200
content-type: application/json
link: <https://acme.okta.com/api/v1/logs?limit=1&after=...>; rel="next"
Common pitfalls
Wrong time zone in since/until
Mistake: using local time without converting to UTC, or omitting the trailing Z.
Symptom: the suspicious event is “missing” even though you know it happened.
Fix: rerun with explicit UTC timestamps like 2026-08-10T13:00:00Z and widen the window by 15 minutes on both sides.
Only searching for user.session.start
Mistake: filtering immediately to one event type.
Symptom: failed sign-ins, denied policy evaluations, or MFA challenges never appear, so the timeline looks incomplete.
Fix: pull the full bounded window first, then pivot by actor.alternateId, client.ipAddress, authenticationContext.externalSessionId, and transaction.id.
Not URL-encoding query parameters
Mistake: hand-building the URL with raw timestamps or filter strings.
Symptom: HTTP 400 responses or empty results because the query was parsed incorrectly.
Fix: use curl --get --data-urlencode "since=$START" --data-urlencode "until=$END" for every parameter.
Using the wrong org domain or a custom domain with the wrong token scope
Mistake: sending the token to a different Okta org than the one where the event occurred.
Symptom: HTTP/2 401, HTTP/2 403, or a valid but empty result set.
Fix: print echo "$OKTA_ORG" and verify it is the exact org that generated the alert, then retest with curl -I -H "Authorization: SSWS $OKTA_TOKEN" "$OKTA_ORG/api/v1/logs?limit=1".
Ignoring pagination on busy tenants
Mistake: assuming limit=1000 always captures the whole window.
Symptom: the early or late part of the story is missing in high-volume orgs.
Fix: follow the Link: ... rel="next" header until no next link remains, or reduce the time window and rerun around the suspicious minute.
Treating shared IPs as a definitive attribution
Mistake: concluding the actor solely from client.ipAddress.
Symptom: multiple users or service events appear from the same NAT, VPN egress, or proxy.
Fix: use IP only as a pivot; confirm attribution with actor.alternateId, transaction.id, externalSessionId, and the user agent string.
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