How-to › Authenticate and authorize calls

How to diagnose a 401 or a 403 from a credential#

Read the WWW-Authenticate challenge rather than the status code, so a client can tell the credential failures apart and take the action that fixes each.

Audience
API consumer
Level
intermediate
Topic
Send API keys and bearer tokens
Languages
TypeScript and JavaScript
Verified

Read first: Add a bearer token to fetch without a client library

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
The challenge headerThe API sends one, which most OAuth-protected APIs doNothing, apart from a parser for a header whose grammar allows more shapes than you expectThe API answers 401 with no header at all
Token introspectionYou control the authorization server, or it exposes the endpoint to youA network call per check, and credentials of your own to call it withThe failure is per resource rather than per token
Decoding the tokenThe token is a JWT and you only need its expiry and scopesIt tells you what the issuer claimed, and never that the token was revoked an hour agoThe token is opaque, or revocation matters
The error bodyThe API documents a machine-readable body and you can rely on itThe shape is per API, so the parser you write covers exactly one vendorYou 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.

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.

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 22 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.