Access token is missing the claim you need: how to diagnose and fix it
For developers debugging APIs or auth flows where an access token does not contain a required claim such as roles, groups, email, or tenant data. This runbook gives you a fast decision path to inspect the token, confirm whether the problem is scope, token type, claim mapping, or size limits, and apply the right fix without guessing.
TL;DR — If the claim you need is missing from an access token, the most common cause is that you are looking in the wrong token or you never requested the scope/resource that causes the issuer to include it. Decode the token you actually send to the API, inspect
aud,scp/scope, andtyp, then fix the client request or issuer claim mapping before touching application code. Reading time: ~6 min
The scenario
It is Tuesday afternoon, you just merged a small auth-related change, and your API starts returning 403 for users who definitely had access this morning. You decode the JWT in your browser storage and the roles claim is gone; or your backend is trying to read email, groups, or tenant_id and gets undefined. The login still succeeds, the UI looks normal, and nobody touched the authorization middleware. You need to know whether the token is wrong, the wrong token is being used, or the IdP stopped emitting the claim.
Symptoms
- API returns
401or403even though authentication succeeded. - Backend logs show missing-claim errors such as:
ForbiddenError: required claim "roles" not present in access token
TypeError: Cannot read properties of undefined (reading 'includes')
JWT validation failed: claim "groups" missing
- Decoded token payload lacks the expected field:
{
"iss": "https://issuer.example.com/",
"sub": "00u123...",
"aud": "api://backend",
"scp": "read:orders",
"exp": 1790861322
}
- The claim exists in the ID token or
/userinforesponse, but not in the access token. - Access token is opaque, not a JWT, so local decoding shows garbage or only a random string.
- After a user is added to a group/role, old tokens continue to miss the claim until re-login or refresh.
- Large group memberships produce truncated or replaced claims, for example a provider-specific overage indicator instead of a full
groupsarray.
Likely causes
| Cause | How common | Quick check |
|---|---|---|
| You are inspecting the wrong token type or the wrong token instance | Very common | `jq -R 'split(".") |
| The client did not request the scope/resource/audience that triggers the claim | Very common | `jq -R 'split(".") |
| The issuer is not configured to emit that claim in access tokens | Common | In your IdP dashboard, open the application/API claim mapping page for the access token and inspect included claims |
The claim only exists in /userinfo or ID token, not access token by design | Common | `curl -s -H "Authorization: Bearer $ACCESS_TOKEN" "$USERINFO_URL" |
| Token is stale; user attributes changed after token issuance | Common | `jq -R 'split(".") |
| Claim was dropped due to token size/group overage limits | Less common | `python3 - <<'PY' |
| import os | ||
| print(len(os.environ.get('ACCESS_TOKEN',''))) | ||
| PY` | ||
| Your app or gateway strips/remaps claims after validation | Less common | grep -R "roles|groups|claim" -n ./ |
Step-by-step diagnosis
- Decode the exact token your API receives.
TOKEN='eyJ...'
printf '%s' "$TOKEN" | jq -R 'split(".")|if length==3 then .[1]|@base64d|fromjson else {opaque:true,value:.} end'
If output shows {"opaque":true,...} or decoding fails with parse error, you are not dealing with a JWT access token. Jump to Fixes → The claim only exists in /userinfo or ID token, not access token by design. If it decodes, continue.
- Confirm token type, audience, and scopes.
printf '%s' "$TOKEN" | jq -R 'split(".")|.[1]|@base64d|fromjson|{typ,aud,azp,client_id,scope,scp,iss,sub}'
This is your problem if aud points to a different API than the one you are calling, or scope/scp does not include the permission that normally causes the claim to be present. If typ indicates ID token semantics or the token came from browser session storage while the API receives a different bearer token, jump to Fixes → You are inspecting the wrong token type or the wrong token instance. If aud/scope is wrong, jump to Fixes → The client did not request the scope/resource/audience that triggers the claim.
- Compare access token vs ID token vs
/userinfo.
printf '%s' "$ID_TOKEN" | jq -R 'split(".")|.[1]|@base64d|fromjson' > /tmp/id.json
printf '%s' "$ACCESS_TOKEN" | jq -R 'split(".")|.[1]|@base64d|fromjson' > /tmp/access.json
jq -s '.[0] as $id | .[1] as $at | {id_only:($id- $at), access_only:($at- $id)}' /tmp/id.json /tmp/access.json
curl -s -H "Authorization: Bearer $ACCESS_TOKEN" "$USERINFO_URL" | jq .
If the claim exists in the ID token or /userinfo but not the access token, that usually means the issuer intentionally separates identity claims from API authorization claims. Jump to Fixes → The issuer is not configured to emit that claim in access tokens or Fixes → The claim only exists in /userinfo or ID token, not access token by design, depending on whether your API truly needs it.
- Check token freshness.
printf '%s' "$TOKEN" | jq -R 'split(".")|.[1]|@base64d|fromjson|{iat,exp,nbf,auth_time}'
date -u +%s
This is your problem if iat predates the user’s role/group change, or if your app silently reuses a cached access token after authorization data changed. Jump to Fixes → Token is stale; user attributes changed after token issuance.
- Check for oversized/group-overage behavior.
python3 - <<'PY'
import os,base64,json
jwt=os.environ['ACCESS_TOKEN']
print('token_length=', len(jwt))
payload=json.loads(base64.urlsafe_b64decode(jwt.split('.')[1]+'=='))
print('keys=', sorted(payload.keys()))
PY
This is your problem if the token is unusually large, reverse proxies reject it with header-size errors, or the expected groups array is replaced by a provider-specific indirection/overage marker. Typical proxy symptom:
HTTP/1.1 400 Bad Request
Server: nginx
Connection: close
Request Header Or Cookie Too Large
Jump to Fixes → Claim was dropped due to token size/group overage limits.
- Verify your app is not dropping the claim after validation.
grep -R "roles\|groups\|claim\|principal\|jwt" -n ./src ./internal ./pkg 2>/dev/null
This is your problem if middleware maps only a subset of claims, normalizes names incorrectly, or reads nested claims from the wrong path. Jump to Fixes → Your app or gateway strips/remaps claims after validation.
Fixes
You are inspecting the wrong token type or the wrong token instance
Use the bearer token from the actual API request, not whatever is easiest to copy from local storage.
curl -sv https://api.example.com/orders -H "Authorization: Bearer $ACCESS_TOKEN" 2>&1 | sed -n '/> Authorization:/p;/< HTTP/p'
If your frontend holds both ID and access tokens, label them explicitly and stop reading authorization data from the ID token in backend code.
// bad
const roles = decodedIdToken.roles;
// good
const roles = decodedAccessToken.roles;
If a gateway exchanges the incoming token for another token, inspect the post-exchange token at the gateway/backend boundary.
Verify it worked:
printf '%s' "$ACCESS_TOKEN" | jq -R 'split(".")|.[1]|@base64d|fromjson|has("roles")'
The client did not request the scope/resource/audience that triggers the claim
Request the correct audience/resource and scopes in the authorization request or token request.
curl -sS -X POST "$TOKEN_URL" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode 'client_id=YOUR_CLIENT_ID' \
--data-urlencode 'client_secret=YOUR_CLIENT_SECRET' \
--data-urlencode 'code=AUTH_CODE' \
--data-urlencode 'redirect_uri=https://app.example.com/callback' \
--data-urlencode 'scope=openid profile email read:orders' \
--data-urlencode 'audience=api://backend'
For machine-to-machine flows, request the API resource explicitly.
curl -sS -X POST "$TOKEN_URL" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'client_id=YOUR_CLIENT_ID' \
--data-urlencode 'client_secret=YOUR_CLIENT_SECRET' \
--data-urlencode 'scope=read:orders write:orders' \
--data-urlencode 'audience=api://backend'
Trade-off: adding broad scopes just to get a claim is a bad pattern. Prefer a narrow custom claim or introspection/userinfo lookup if the claim is identity data, not API authorization data.
Verify it worked:
printf '%s' "$ACCESS_TOKEN" | jq -R 'split(".")|.[1]|@base64d|fromjson|{aud,scope,scp,roles}'
The issuer is not configured to emit that claim in access tokens
In your IdP dashboard, open the application or API settings where token claims are mapped, then add the claim to the access token, not just the ID token. Typical options are named "Token claims", "Attribute mappings", or "Claims for access token" depending on provider.
If your issuer supports custom claims from user attributes, map them explicitly.
{
"claim": "tenant_id",
"source": "user.attribute.tenant_id",
"include_in": ["access_token"]
}
If your API uses roles, emit a stable claim name and update your verifier to read that exact field.
{
"claim": "roles",
"source": "user.roles",
"include_in": ["access_token"]
}
Trade-off: every extra claim increases token size and leak surface. Do not emit PII into access tokens unless the API truly needs it.
Verify it worked:
printf '%s' "$ACCESS_TOKEN" | jq -R 'split(".")|.[1]|@base64d|fromjson|.tenant_id,.roles'
The claim only exists in /userinfo or ID token, not access token by design
If the missing field is identity/profile data such as name, email, or picture, fetch it from the userinfo endpoint instead of forcing it into access tokens.
curl -sS -H "Authorization: Bearer $ACCESS_TOKEN" "$USERINFO_URL" | jq '{sub,email,name,picture}'
If your backend needs the data, call userinfo once and cache by sub for a short TTL.
redis-cli SETEX "userinfo:$SUB" 300 "$(curl -sS -H "Authorization: Bearer $ACCESS_TOKEN" "$USERINFO_URL")"
If the API needs authorization data, do not use ID token claims as a substitute; configure access-token claims or query your authorization store directly.
Verify it worked:
curl -sS -H "Authorization: Bearer $ACCESS_TOKEN" "$USERINFO_URL" | jq 'has("email")'
Token is stale; user attributes changed after token issuance
Force a fresh token after role/group changes. For browser apps, sign out and re-authenticate or trigger a refresh-token grant if your client supports it.
curl -sS -X POST "$TOKEN_URL" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=refresh_token' \
--data-urlencode 'client_id=YOUR_CLIENT_ID' \
--data-urlencode 'client_secret=YOUR_CLIENT_SECRET' \
--data-urlencode 'refresh_token=YOUR_REFRESH_TOKEN'
If your backend caches authorization decisions, evict them when role/group membership changes.
redis-cli DEL "authz:user:$SUB"
Trade-off: shorter token lifetimes reduce stale auth but increase refresh traffic and failure modes during IdP incidents.
Verify it worked:
printf '%s' "$NEW_ACCESS_TOKEN" | jq -R 'split(".")|.[1]|@base64d|fromjson|{iat,roles,groups}'
Claim was dropped due to token size/group overage limits
Stop putting huge arrays into access tokens. Replace full group lists with compact role claims, group IDs, or an overage lookup pattern in your app.
If nginx or another proxy rejects large headers, you can raise limits temporarily while you implement the real fix.
⚠️ Increasing header buffers can increase memory use and masks the real problem. It does not fix oversized-token design.
large_client_header_buffers 8 32k;
Reload:
nginx -t && sudo systemctl reload nginx
Then reduce token size at the issuer by emitting fewer claims or shorter identifiers.
Verify it worked:
python3 - <<'PY'
import os
print(len(os.environ['ACCESS_TOKEN']))
PY
Your app or gateway strips/remaps claims after validation
Inspect the verified principal object, not the raw token assumptions. Common bugs: expecting roles but validator maps to role, expecting top-level groups while middleware nests claims under claims.groups, or converting arrays to comma-separated strings.
app.use((req, _res, next) => {
console.log(JSON.stringify(req.user || req.auth || {}, null, 2));
next();
});
If a reverse proxy forwards claims as headers, confirm the header names and casing.
curl -I https://api.example.com/debug -H "Authorization: Bearer $ACCESS_TOKEN"
Typical bad pattern:
const roles = req.user.roles || [];
Safer pattern:
const claims = req.user?.claims || req.auth || req.user || {};
const roles = claims.roles ?? claims.role ?? [];
Verify it worked:
grep -R "const roles =" -n ./src && curl -sS https://api.example.com/debug -H "Authorization: Bearer $ACCESS_TOKEN" | jq .
Prevention
- Add a CI test that requests a real token from a non-production issuer and asserts required claims for each flow.
TOKEN=$(./scripts/get-token.sh)
printf '%s' "$TOKEN" | jq -R 'split(".")|.[1]|@base64d|fromjson|select(has("roles"))|.aud'
- Log token metadata at auth boundaries without logging the full token:
iss,sub,aud,scope/scp, and claim presence booleans.
{"event":"auth.accepted","iss":"https://issuer.example.com/","aud":"api://backend","sub":"00u123","scp":"read:orders","has_roles":false}
- Pin claim names in code and config; do not scatter string literals.
export const REQUIRED_CLAIMS = ["sub", "aud", "roles"] as const;
- Add a synthetic check that calls a protected endpoint with a known test user and fails if authorization regresses.
curl -fsS https://api.example.com/admin/ping -H "Authorization: Bearer $TEST_TOKEN" >/dev/null
- Alert on proxy/header-size failures that often correlate with oversized tokens.
grep -R "Request Header Or Cookie Too Large\|upstream sent too big header" /var/log/nginx/
- Document which data belongs in access tokens vs userinfo vs backend lookup, and enforce it in code review with a short checklist.
[ ] API authorization data comes from access token or authz service
[ ] Profile data comes from userinfo or user profile service
[ ] No new PII added to access tokens without review
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