Compromised access token: scope what it could reach and what it touched
For developers handling a suspected leaked API, OAuth, or service access token. This runbook shows how to determine the token's effective permissions, identify the systems and data it actually accessed from logs, and revoke or constrain it without guessing.
TL;DR — When a token is exposed, answer two separate questions in parallel: what the token was allowed to do, and what it actually did. The fastest path is: identify the token issuer and subject, inspect its scopes/claims or introspection result, then pivot through API gateway/app/cloud audit logs using the token ID, subject, client ID, source IP, and time window; revoke first if active abuse is possible, but preserve enough evidence to reconstruct access. Reading time: ~6 min
The scenario
It is 3:40 PM on a Tuesday and someone pastes a screenshot into Slack showing a bearer token in a CI log. The token was valid at least an hour ago, your deploys use the same identity provider for internal APIs, and nobody can immediately tell whether this token was read-only, admin, or tied to a machine account with broad cloud access. Security wants impact in 30 minutes: which systems were reachable, which endpoints were actually hit, and whether customer data moved. You need a repeatable way to scope blast radius without hand-waving or destroying the evidence you still need.
Symptoms
- Secret scanner alert or human report showing a token-like string in logs, chat, browser devtools, crash dump, or
.envupload. - API logs with a sudden spike of
401,403, or successful200/201/204requests under an unexpected principal. - Identity provider or auth proxy logs showing token issuance/introspection for an unfamiliar
client_id,sub, or source IP. - Cloud audit logs showing actions by a service account or assumed role outside normal hours.
- Typical app log lines:
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
user_id=svc-deploy client_id=ci-runner scope="read:users write:users admin:billing"
request_id=7f3c... method=GET path=/v1/customers status=200 bytes=18342
request_id=1d29... method=POST path=/v1/tokens/revoke status=403 error="insufficient_scope"
- JWT decode output showing long expiry or broad claims:
{
"iss": "https://auth.example.com/",
"sub": "svc-ci",
"aud": "api.example.internal",
"scope": "read:users write:users admin:billing",
"jti": "4d7f4a1e-...",
"exp": 1793372400,
"iat": 1793368800,
"client_id": "ci-runner"
}
- Opaque token introspection returning active:
{
"active": true,
"scope": "read:users write:users",
"client_id": "ci-runner",
"username": "svc-ci",
"token_type": "Bearer",
"exp": 1793372400,
"sub": "svc-ci",
"aud": "api.example.internal",
"jti": "4d7f4a1e-..."
}
Likely causes
| Cause | How common | Quick check |
|---|---|---|
| Token is still active and its scopes/claims are broader than expected | Very common | `python - <<'PY' |
| import os,base64,json | ||
| jwt=os.environ['TOKEN'].split('.') | ||
| p=lambda s: s + '=' * (-len(s)%4) | ||
| print(json.dumps(json.loads(base64.urlsafe_b64decode(p(jwt[1]))), indent=2)) | ||
| PY` | ||
| Token is opaque, so teams guess at permissions instead of introspecting it | Common | `curl -s -u "$INTROSPECT_CLIENT:$INTROSPECT_SECRET" -d token="$TOKEN" https://auth.example.com/oauth2/introspect |
| Audit/API logs do not include token identifiers, so you can see requests but not tie them to this token | Common | `grep -R "jti|client_id|sub|Authorization" /var/log/nginx /var/log/app 2>/dev/null |
| The token maps to a machine account/service principal with inherited cloud or database privileges beyond API scopes | Less common | aws sts get-caller-identity --profile suspected-profile |
| The token was copied into build artifacts, shell history, or crash dumps, so multiple actors/processes may have used it | Less common | `rg -n --hidden --no-ignore "$TOKEN |
Step-by-step diagnosis
- Identify token type and preserve a working copy.
export TOKEN='paste-token-here'
printf '%s' "$TOKEN" | awk -F. '{print NF}'
- Output
3usually means JWT/JWS;1usually means opaque token. - If the token contains spaces/newlines from chat wrapping, fix that first; malformed JWT decode errors like
Invalid header paddingare not evidence of revocation. - If JWT, go to step 2. If opaque, go to step 3.
- Decode JWT claims locally and note
iss,sub,aud,scope,client_id,jti,exp.
python - <<'PY'
import os,base64,json
parts=os.environ['TOKEN'].split('.')
def dec(s):
s += '=' * (-len(s) % 4)
return json.loads(base64.urlsafe_b64decode(s))
print('HEADER=')
print(json.dumps(dec(parts[0]), indent=2))
print('CLAIMS=')
print(json.dumps(dec(parts[1]), indent=2))
PY
- This is your problem if you see broad scopes like
admin:*, wildcard audiences, longexp, or a service subject such assvc-*. - Jump to Fixes → Active token with excessive scope or long lifetime if still valid and abuse is plausible; otherwise continue to step 4.
- Introspect opaque token at the issuer.
curl -s -u "$INTROSPECT_CLIENT:$INTROSPECT_SECRET" \
-d token="$TOKEN" \
https://auth.example.com/oauth2/introspect | jq .
"active": trueplus useful fields (scope,sub,client_id,aud,exp, sometimesjti) gives you the effective permission set.{"active":false}means it is expired/revoked; still continue to step 4 because historical use matters.- If introspection returns
401or403, use your IdP admin path to inspect the token or client app and then continue.
- Build the search key set and time window.
export SUB='svc-ci'
export CLIENT_ID='ci-runner'
export JTI='4d7f4a1e-...'
export START='2026-10-30T14:00:00Z'
export END='2026-10-30T16:00:00Z'
- Search keys in order of confidence:
jti> token fingerprint/hash >sub+client_id+ source IP + exact time. - If you do not have
jti, compute a safe fingerprint for matching without reprinting the secret:
printf '%s' "$TOKEN" | sha256sum
- Continue to step 5.
- Query API gateway, reverse proxy, and app logs for actual requests.
journalctl --since "$START" --until "$END" -u nginx -u your-app.service \
| grep -E "$JTI|$SUB|$CLIENT_ID|$(printf '%s' "$TOKEN" | sha256sum | cut -d' ' -f1)"
- This is your problem if you find successful requests (
200/201/204/206) on sensitive paths, large response sizes, or write methods (POST/PUT/PATCH/DELETE). - Example shape:
Oct 30 15:02:11 api your-app[4123]: request_id=7f3c sub=svc-ci client_id=ci-runner jti=4d7f... method=GET path=/v1/customers status=200 bytes=18342 ip=198.51.100.24
Oct 30 15:03:44 api your-app[4123]: request_id=8a91 sub=svc-ci client_id=ci-runner jti=4d7f... method=POST path=/v1/users/42/role status=204 ip=198.51.100.24
- If logs only show
suband notjti, treat all requests by that principal in the window as suspect unless you can correlate source IP/user-agent uniquely. Then continue to step 6.
- Check issuer and cloud audit logs for non-API actions.
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=Username,AttributeValue="$SUB" \
--max-results 50
- This is your problem if the same principal performed storage reads, secret access, role assumptions, or database auth outside the API path.
- For Kubernetes, also check audit logs for the service account subject; for databases, check connection logs by role name.
- Jump to Fixes → Service principal or inherited privileges expanded the blast radius if you see cloud/infra actions.
- Search for secondary exposure locations.
⚠️ Searching developer homes and CI workspaces can expose more secrets in output. Redirect results to a restricted file and avoid pasting raw token values into shared channels.
rg -n --hidden --no-ignore "${TOKEN:0:16}|$SUB|$CLIENT_ID" \
. ~ /var/lib/jenkins /tmp 2>/dev/null > /tmp/token-exposure-hits.txt
sed -n '1,50p' /tmp/token-exposure-hits.txt
- This is your problem if the token appears in artifacts, shell history, crash dumps, or test fixtures; your incident may involve more than one user/process.
- Jump to Fixes → Token leaked into artifacts or logs and may have been reused.
Fixes
Active token with excessive scope or long lifetime
⚠️ Revoking a machine token can break production jobs immediately. If the token belongs to CI, deploy, or a background worker, identify dependent jobs first or rotate to a replacement before revocation.
- Revoke or disable the token/client at the issuer.
curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
-d token="$TOKEN" \
https://auth.example.com/oauth2/revoke -i
Typical success is HTTP/1.1 200 OK even if the token was already invalid.
2. Rotate the client secret or signing key if the token can be re-minted by an exposed client credential.
3. Reduce scope and lifetime in issuer config: remove admin scopes from the client app, set access token TTL to minutes not days, and split machine identities per job/environment.
4. If JWTs are self-contained and cannot be individually revoked, rotate the signing key or enforce denylisting by jti at the gateway until expiry.
Verify it worked:
curl -s -u "$INTROSPECT_CLIENT:$INTROSPECT_SECRET" -d token="$TOKEN" https://auth.example.com/oauth2/introspect | jq .active
Opaque token was not introspected, so permissions were guessed
- Use the issuer's introspection endpoint or admin API and record
active,scope,sub,client_id,aud,exp.
curl -s -u "$INTROSPECT_CLIENT:$INTROSPECT_SECRET" \
-d token="$TOKEN" https://auth.example.com/oauth2/introspect | jq '{active,scope,sub,client_id,aud,exp,jti}'
- If your services currently authorize opaque tokens without caching introspection results, add short-lived cache keyed by token hash to avoid hot-looping the IdP during incident review.
- Update the runbook and dashboards to surface introspection fields directly. Verify it worked:
curl -s -u "$INTROSPECT_CLIENT:$INTROSPECT_SECRET" -d token="$TOKEN" https://auth.example.com/oauth2/introspect | jq .scope
Missing token identifiers in logs prevented attribution
- Add structured auth context to request logs at the app or gateway:
sub,client_id,jti,aud, source IP, method, path, status, bytes, request ID.
{"ts":"2026-10-30T15:02:11Z","request_id":"7f3c","sub":"svc-ci","client_id":"ci-runner","jti":"4d7f...","aud":"api.example.internal","ip":"198.51.100.24","method":"GET","path":"/v1/customers","status":200,"bytes":18342}
- Never log raw bearer tokens. Log
sha256(token)if you need a stable join key.
TOKEN_SHA256=$(printf '%s' "$TOKEN" | sha256sum | cut -d' ' -f1)
- If using nginx in front of an auth service, pass verified identity headers from the auth layer and log those headers, not
Authorization.
log_format json escape=json '{"time":"$time_iso8601","request_id":"$request_id","sub":"$http_x_auth_sub","client_id":"$http_x_auth_client_id","jti":"$http_x_auth_jti","status":$status,"method":"$request_method","path":"$uri","bytes":$body_bytes_sent,"ip":"$remote_addr"}';
access_log /var/log/nginx/access.json json;
Verify it worked:
tail -n 2 /var/log/nginx/access.json | jq .
Service principal or inherited privileges expanded the blast radius
- Enumerate attached roles/policies/DB grants for the principal and remove anything not required.
aws iam list-attached-role-policies --role-name svc-ci
aws iam list-role-policies --role-name svc-ci
psql "$DATABASE_URL" -c "\du+ svc_ci"
- Replace one broad machine identity with separate identities per workload/environment.
- If the token can assume other roles, remove the trust relationship or session policy path that allowed lateral movement.
- Rotate any downstream credentials the principal could read: secrets manager entries, database passwords, kube secrets. Verify it worked:
aws iam simulate-principal-policy --policy-source-arn arn:aws:iam::123456789012:role/svc-ci --action-names s3:GetObject secretsmanager:GetSecretValue
Token leaked into artifacts or logs and may have been reused
- Delete or restrict access to the artifact/log location, then rotate the token and any credential that could mint it.
- Purge the token from CI logs where your platform supports redaction; if not, expire retention and restrict access immediately.
- Remove the token from shell history and local files on affected hosts.
history | grep '${TOKEN:0:16}'
unset TOKEN
sed -i.bak '/partial-token-prefix/d' ~/.bash_history ~/.zsh_history 2>/dev/null
- In CI, mask secrets and stop printing env on failure.
set +x
env | grep -viE 'token|secret|password|key'
Verify it worked:
rg -n --hidden --no-ignore "${TOKEN:0:16}" . ~ /var/lib/jenkins /tmp 2>/dev/null | head
Prevention
- Log stable token metadata, not the token. Add
sub,client_id,jti,aud, request ID, IP, method, path, status, bytes to every authenticated request log.
{"sub":"svc-ci","client_id":"ci-runner","jti":"4d7f...","ip":"198.51.100.24","method":"GET","path":"/v1/customers","status":200}
- Add a CI check that fails on bearer tokens and common secret patterns before logs/artifacts are published.
trufflehog filesystem --fail --no-update .
- Enforce short access-token TTLs and separate refresh/client credentials from runtime logs. For machine tokens, prefer 5-15 minute access tokens and rotate automatically.
{"access_token_ttl_minutes":10,"rotate_client_secret_days":30}
- Alert on unusual token use: new source IP/ASN, off-hours access, admin scope on read-only service, or high-volume reads by a machine principal.
jq 'select(.client_id=="ci-runner" and .bytes > 10000000)' /var/log/nginx/access.json
- Add a denylist hook for
jtior token hash at the gateway for emergency containment when JWTs are otherwise valid until expiry.
map $http_x_auth_jti $blocked_jti { default 0; 4d7f4a1e-... 1; }
if ($blocked_jti) { return 401; }
- Keep an incident query template checked into the repo so responders do not improvise under pressure.
View as text
[Leaked Token]
|
| 1. Decode/introspect
v
[Claims: sub client_id jti scope exp]
|
| 2. Search logs by jti/sub/time
v
[API/App Access]
|
| 3. Pivot by principal
v
[Cloud/DB Audit]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