Validate JWT access tokens in your API without accepting bad claims
For developers wiring token validation into an API and trying to avoid subtle auth bugs. This shows the exact checks to implement for JWT access tokens: signature, issuer, audience, expiry, time skew, and the claims that are commonly skipped but should not be.
TL;DR — A JWT is not valid just because it decodes. In your API, verify the signature with the issuer’s JWKS, pin the expected
issandaud, reject expired/not-yet-valid tokens with a small clock skew, and explicitly require claims likesub,client_id/azp, andscopeorscpif your authorization depends on them. Reading time: ~5 min
Goal
When you are done, your API rejects forged, expired, wrong-issuer, wrong-audience, and under-scoped JWT access tokens with deterministic 401/403 responses, and accepts only tokens that match your configured issuer, audience, signing keys, and required claims.
Prerequisites
- Access to your API codebase and deployment config
- The issuer URL for your identity provider, for example
https://id.example.com/ - The audience your API expects, for example
api://orders-apiorhttps://api.example.com - A sample JWT access token from your login flow or test client
curlinstalled — check withcurl --versionjqinstalled — check withjq --version- One runtime to test locally:
- Node.js >= 20 — check with
node --version - or Python >= 3.11 — check with
python3 --version
- Node.js >= 20 — check with
- Outbound network access from your API to fetch JWKS, or a plan to preload keys if your environment blocks egress
Steps
Step 1: Discover the issuer metadata and JWKS URL
Run the issuer discovery request and capture the exact issuer and jwks_uri values.
ISSUER="https://id.example.com"
curl -fsS "$ISSUER/.well-known/openid-configuration" | jq '{issuer, jwks_uri, id_token_signing_alg_values_supported}'
You should see JSON with your exact issuer and a reachable jwks_uri, for example:
{
"issuer": "https://id.example.com/",
"jwks_uri": "https://id.example.com/.well-known/jwks.json",
"id_token_signing_alg_values_supported": ["RS256"]
}
Step 2: Confirm the JWKS endpoint returns signing keys
Fetch the JWKS and verify there is at least one key with a kid and a supported asymmetric algorithm.
JWKS_URI="https://id.example.com/.well-known/jwks.json"
curl -fsS "$JWKS_URI" | jq '.keys[] | {kid, kty, alg, use}'
You should see one or more keys like this:
{
"kid": "a1b2c3d4",
"kty": "RSA",
"alg": "RS256",
"use": "sig"
}
Step 3: Decode one token locally and inspect the claims you will enforce
Do not trust the token yet; just inspect it. This lets you pin the exact claim names your provider uses for audience, scopes, and client identity.
TOKEN='eyJ...'
python3 - <<'PY' "$TOKEN"
import base64, json, sys
parts = sys.argv[1].split('.')
for i, name in [(0,'header'), (1,'payload')]:
s = parts[i] + '=' * (-len(parts[i]) % 4)
print(name + ':')
print(json.dumps(json.loads(base64.urlsafe_b64decode(s)), indent=2))
PY
You should see a header with alg and kid, and a payload containing at least iss, sub, aud, exp, and usually iat.
Step 4: Implement strict validation in your API
Use a JWT library that validates against remote JWKS and lets you pin issuer, audience, algorithms, and clock tolerance. Example in Node.js with jose:
npm install jose@5
import { createRemoteJWKSet, jwtVerify } from 'jose'
const ISSUER = 'https://id.example.com/'
const AUDIENCE = 'api://orders-api'
const JWKS = createRemoteJWKSet(new URL('https://id.example.com/.well-known/jwks.json'))
export async function validateAccessToken(token) {
const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ['RS256'],
clockTolerance: '60s',
typ: 'JWT'
})
if (!payload.sub || typeof payload.sub !== 'string') {
throw new Error('invalid_token: missing sub')
}
if (!payload.scope && !payload.scp) {
throw new Error('invalid_token: missing scope/scp')
}
const scopes = typeof payload.scope === 'string'
? payload.scope.split(' ')
: Array.isArray(payload.scp)
? payload.scp
: typeof payload.scp === 'string'
? payload.scp.split(' ')
: []
if (!scopes.includes('orders:read')) {
throw new Error('insufficient_scope: orders:read required')
}
if (payload.azp && payload.azp !== 'orders-web') {
throw new Error('invalid_token: unexpected azp')
}
return { payload, protectedHeader }
}
You should see your API start cleanly and token validation failures produce controlled exceptions instead of uncaught library errors.
Step 5: Return the correct HTTP status and WWW-Authenticate header
Map authentication failures to 401 and authorization failures to 403. This matters for clients and for debugging.
app.get('/orders', async (req, res) => {
try {
const auth = req.headers.authorization || ''
const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''
if (!token) {
res.set('WWW-Authenticate', 'Bearer error="invalid_token", error_description="missing bearer token"')
return res.status(401).json({ error: 'invalid_token' })
}
await validateAccessToken(token)
return res.json({ ok: true })
} catch (err) {
const msg = String(err.message || err)
if (msg.startsWith('insufficient_scope')) {
res.set('WWW-Authenticate', 'Bearer error="insufficient_scope", scope="orders:read"')
return res.status(403).json({ error: 'insufficient_scope' })
}
res.set('WWW-Authenticate', `Bearer error="invalid_token", error_description="${msg}"`)
return res.status(401).json({ error: 'invalid_token' })
}
})
You should see 401 for bad/missing tokens and 403 only when the token is valid but lacks required scope.
Step 6: Test the exact failure modes before shipping
Use real requests against your API and verify each check fails for the right reason.
API='http://localhost:3000/orders'
curl -i "$API"
curl -i -H "Authorization: Bearer not-a-jwt" "$API"
curl -i -H "Authorization: Bearer $TOKEN" "$API"
You should see output shaped like this:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="missing bearer token"
content-type: application/json; charset=utf-8
{"error":"invalid_token"}
and for a good token:
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
{"ok":true}
Verify it works
Run these checks end to end.
# 1. Discovery works
curl -fsS "https://id.example.com/.well-known/openid-configuration" | jq -r '.issuer,.jwks_uri'
# 2. JWKS works
curl -fsS "https://id.example.com/.well-known/jwks.json" | jq -r '.keys[].kid'
# 3. API rejects missing token
curl -i "http://localhost:3000/orders"
# 4. API accepts a valid token
curl -i -H "Authorization: Bearer $TOKEN" "http://localhost:3000/orders"
Expected results:
- Discovery returns the exact issuer you configured in code, including trailing slash if the provider uses one
- JWKS returns at least one
kid - Missing token returns
401 Unauthorized - Valid token returns
200 OK - A valid token without
orders:readreturns403 ForbiddenwithWWW-Authenticate: Bearer error="insufficient_scope"
Common pitfalls
Using decode instead of verify
Mistake: reading JWT claims with a base64 decoder and treating them as trusted.
Symptom: any forged token with edited sub or scope is accepted.
Fix: replace decode-only logic with library verification against JWKS, issuer, audience, and allowed algorithms.
Issuer string mismatch, usually the trailing slash
Mistake: configuring issuer as https://id.example.com when discovery says https://id.example.com/.
Symptom: library error like unexpected "iss" claim value or generic JWTClaimValidationFailed.
Fix: copy the issuer value exactly from /.well-known/openid-configuration.
Accepting the wrong audience
Mistake: validating only signature and expiry, or using a frontend client ID as aud instead of the API audience.
Symptom: tokens minted for another API are accepted by yours.
Fix: set audience: 'api://orders-api' to the exact audience your API expects and reject all others.
Not handling key rotation
Mistake: hardcoding one public key or one kid forever.
Symptom: auth starts failing after the identity provider rotates signing keys; errors mention no matching key or unknown kid.
Fix: use the provider’s JWKS endpoint and a library that refreshes keys by kid.
Treating scope and scp as the same without checking format
Mistake: assuming scopes are always in scope as a space-delimited string.
Symptom: valid tokens are rejected because the provider sends scp as an array or different claim name.
Fix: inspect a real token, then parse both scope and scp exactly as your provider emits them.
Ignoring nbf and clock skew
Mistake: setting zero tolerance in distributed systems with slightly skewed clocks.
Symptom: intermittent token not active yet or jwt not active failures right after login.
Fix: set a small tolerance such as clockTolerance: '60s' and keep system clocks synced with NTP.
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