# 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.

Source: https://voxgig.com/howto/diagnose-401-and-403-from-a-credential

- Audience: api-consumer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-09-06
- Published: 2026-09-06

## 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](https://www.rfc-editor.org/rfc/rfc6750#section-3), 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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/WWW-Authenticate) | 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](https://www.rfc-editor.org/rfc/rfc7662) | 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](https://www.rfc-editor.org/rfc/rfc9068) | 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](https://www.rfc-editor.org/rfc/rfc9457) | 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.

```ts title="probe.mjs"
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](https://www.rfc-editor.org/rfc/rfc9110#section-15.5.4) 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.

```bash
node demo.mjs
```

```text output
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.

```bash
node --test probe.test.mjs
```

```text output
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

- [Refresh an access token once under concurrent requests](/howto/refresh-a-token-once-under-concurrent-requests)

- [Add a bearer token to fetch without a client library](/howto/bearer-token-fetch-wrapper)

## 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.