An integration that ran for months starts failing with 401. The retry logic refreshes the token, gets a fresh one, and fails again with the same status. Somewhere between an expired token, a revoked key and a scope nobody granted, one of three fixes applies, and the status code is the same for all three.
What you get
You will end up with a client that classifies a credential failure into refresh, request a scope, or get a new credential. This is for you if every 401 triggers a refresh whatever caused it.
Short answer
Parse the WWW-Authenticate response header. An error of invalid_token with an expiry description means refresh. An error of insufficient_scope names the scope the token lacks. An invalid_token with no expiry description means the credential is gone, and refreshing will not bring it back. The status code alone cannot separate those three, and each needs a different fix.
You will need
Node 22 or later, and an API that follows RFC 6750, which defines the challenge header for bearer tokens. APIs that answer with a bare status and no challenge are covered in the last section.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| The challenge header | The API sends one, which most OAuth-protected APIs do | Nothing, apart from a parser for a header whose grammar allows more shapes than you expect | The API answers 401 with no header at all |
| Token introspection | You control the authorization server, or it exposes the endpoint to you | A network call per check, and credentials of your own to call it with | The failure is per resource rather than per token |
| Decoding the token | The token is a JWT and you only need its expiry and scopes | It tells you what the issuer claimed, and never that the token was revoked an hour ago | The token is opaque, or revocation matters |
| The error body | The API documents a machine-readable body and you can rely on it | The shape is per API, so the parser you write covers exactly one vendor | You call several APIs and want one code path |
Decoding is the fast one and the one that lies most readily: a JWT that has not expired can still have been revoked, and only the issuer knows. Introspection asks the issuer directly and pays a round trip for the answer. The challenge header sits between them, because the resource server has already done both checks and is telling you which one failed.
Read the challenge, not the status
The header carries name-value parameters, and three of them decide the action.
export function classify(status, challenge) {
if (status !== 401 && status !== 403) return 'not a credential failure'
const error = /error="([^"]+)"/.exec(challenge ?? '')?.[1]
const description = /error_description="([^"]+)"/.exec(challenge ?? '')?.[1] ?? ''
const scope = /scope="([^"]+)"/.exec(challenge ?? '')?.[1]
if (error === undefined) {
return status === 401
? 'no usable credential reached the server, check that the header was sent'
: 'authenticated, refused by policy, and the server gave no machine-readable reason'
}
if (error === 'insufficient_scope') return `token is valid, it lacks the scope ${scope}`
if (error === 'invalid_token' && /expire/i.test(description)) return 'token expired, refresh it'
if (error === 'invalid_token') return 'token rejected and not because of expiry, get a new one'
if (error === 'invalid_request') return 'the request is malformed, the credential may be fine'
return `challenge carries error=${error}`
}
The distinction that saves the most time is the last pair. An expired token and a revoked token both arrive as invalid_token, and only the description separates them. Refreshing an expired token works. Refreshing a revoked one produces a new token that fails the same way, which is the loop the opening paragraph describes.
Treat 403 as a different question
A 401 says the credential was not accepted. A 403 says it was accepted and the operation is still refused, which RFC 9110 states directly. So a 403 with insufficient_scope is actionable: ask for the scope it names. A 403 with no challenge means the token is fine and something else about the request is not: usually the tenant or the object it addressed.
Retrying a 403 is almost always wrong. The same request with the same credential produces the same answer, and a client that retries it burns quota to learn nothing.
The practical split is between failures a client can act on alone and failures that need a person. An expiry is the first kind: refresh and carry on. A missing scope is the second, because granting it is somebody’s decision at the API you are calling. Code that treats the two the same way either retries something hopeless or escalates something routine. Naming which kind arrived is most of the value this classification has, and it costs one header read per failure.
Check it worked
Run one request per credential state and read the classification.
node demo.mjs
at-good 200 not a credential failure
at-expired 401 token expired, refresh it
at-revoked 401 token rejected and not because of expiry, get a new one
at-narrow 403 token is valid, it lacks the scope invoices:write
at-wrong-tenant 403 authenticated, refused by policy, and the server gave no machine-readable reason
(no header) 401 no usable credential reached the server, check that the header was sent
Two of those are 401 and two are 403, and inside each pair the fix differs. The fifth line is the case with no machine-readable answer, and reporting it as unexplained is better than guessing at it.
node --test probe.test.mjs
1..3
# tests 3
# suites 0
# pass 3
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 112.932992
When it goes wrong
These regular expressions read a header whose grammar is richer than they are. Parameters may appear in any order, values may be unquoted tokens, and a response may carry several challenges for several schemes in one header. A client that calls one API can live with the simple version. A client library published to other people needs a parser that walks the header properly, because the failure is silent. An unquoted value does not match, and the code then reports a case the server has already told it about.
The second failure is an API that answers 401 for authorization. Some do, and no amount of correct parsing fixes it. The two statuses carry different advice, so mapping a scope failure onto 401 sends a caller off to fetch a fresh token that will be refused in exactly the same way. Check the API’s own documentation for which status it uses on a scope failure before you write the branch that refreshes on 401. On such an API that branch refreshes a working token every time a caller asks for something it may not have.
When not to do this
Do not classify a failure you can prevent. A client that knows which scopes it needs can ask for them when the user consents, and fail there rather than in production.
Do not build the classifier into your retry loop. Retry decides whether to try again, and this decides what to do instead. Combining them produces a loop that refreshes on a scope error and gives up on an expiry.
Do not log the challenge header without reading what it contains first. Some servers put the token into the description when they reject it, so a header copied into a log can carry the credential with it.
Do not depend on the description text. It is prose meant for a developer, and its wording is not specified, so a substring match on it will break when the vendor rewords the message. Where the distinction matters, ask the issuer through introspection rather than reading its adjectives.
Related how-tos
Last verified
Verified 2026-09-06 against Node 22.22.2. Both output blocks are what the preceding command printed, against a local server that reproduces the four rejections rather than against a live issuer.