# 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
- Updated: 2026-09-24

## 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.[^1] 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.[^2] 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, which puts the fault in
the status itself, out of reach of any amount of correct parsing. 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.[^3] 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.

[^1]: [Section 3.1](https://www.rfc-editor.org/rfc/rfc6750.html#section-3.1) of RFC 6750 pairs each
error code with the status it should travel with: `invalid_request` with 400, `invalid_token` with
401, and `insufficient_scope` with 403. A failure, it says, typically uses 400, 401, 403, or 405. It
adds that a request carrying no authentication information at all SHOULD NOT get an error code back,
so a bare `Bearer realm="example"` is the specification's own example of a correct answer. The last
line of the page's demo is the case the RFC chose to illustrate.

[^2]: The names date from [RFC 1945](https://www.rfc-editor.org/rfc/rfc1945.html#section-9.4),
HTTP/1.0, in May 1996. 401 Unauthorized is defined there as the request requiring user
authentication, and 403 Forbidden as a request the server understood and refuses, with the remark
that authorization will not help. So the status named for authorization asks for authentication, and
the status named for a refusal is the one that mentions authorization, to rule it out. [RFC
9110](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.5.4), twenty-six years later, keeps
both names, and permits a server to answer a forbidden request with 404 when it would rather not say
that the resource exists.

[^3]: [RFC 6750](https://www.rfc-editor.org/rfc/rfc6750.html#section-3) allows the description so
that a developer can read an explanation, and says it is not meant to be displayed to end users. It
confines the value to printable ASCII without the double quote and the backslash, and says nothing
about the words. Two servers rejecting the same expired token can both be right in different
sentences, and the page's regular expression looks for `expire` in either.