devkult_
tools29converters34
home/blog/401-vs-403
httpauthapi

401 vs 403: Unauthorized or Forbidden?

Two status codes cover almost every "you can't have this" response, and both names are misleading. 401 Unauthorized is really about authentication — who you are. 403 Forbidden is about authorization — what you're allowed to do. Mixing them up confuses clients, breaks login flows and makes debugging harder than it needs to be.

The one-line rule

  • 401: "I don't know who you are." Credentials are missing, expired or invalid. Logging in, or refreshing a token, can fix it.
  • 403: "I know who you are, and no." The identity is fine; the permission isn't. Logging in again won't change anything.

A useful test: would sending different credentials help? If yes, it's a 401. If this user will never be allowed, it's a 403.

What each response should contain

A 401 must include a WWW-Authenticate header telling the client how to authenticate:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="The access token expired"
Content-Type: application/json

{"error": "token_expired"}

The error="invalid_token" parameter comes from RFC 6750 (Bearer tokens) and lets a client tell "you sent nothing" apart from "what you sent is no good". A 403 has no required header, but a body that names the missing permission saves your users a support ticket:

HTTP/1.1 403 Forbidden
Content-Type: application/json

{"error": "insufficient_scope", "required": "orders:write"}

How clients should react

Status What the client should do
401 Refresh the access token once and retry; if that fails, send the user to sign in
403 Don't retry. Show a "you don't have access" message, or request more permissions

This matters most for single-page and mobile apps that refresh tokens automatically. If your API returns 403 for an expired token, the client never tries to refresh and the user is stuck. If it returns 401 for a permissions problem, the client refreshes, retries, gets 401 again — and may loop, or log the user out for no reason.

The exception: hiding the resource with 404

Sometimes even admitting a resource exists leaks information: another user's private document, a private repository, an unreleased product. GitHub answers 404 for private repositories you can't see, and RFC 9110 explicitly allows a server to answer 404 Not Found instead of 403 when it wants to hide the resource.

Rule of thumb: return 403 when the resource's existence is no secret ("this admin page exists, but you're not an admin"), and 404 when it is.

Debugging a 401

  1. Is a credential being sent at all? Look at the request in DevTools → Network. A missing Authorization header is the most common cause — browsers drop it when following a redirect to another origin, and some proxies strip it.
  2. Has the token expired? Paste it into the JWT decoder and compare exp with the current time.
  3. Is it the right token for this API? Check aud (audience) and iss (issuer) — a staging token sent to production fails here.
  4. Is the header formatted correctly? Authorization: Bearer <token>, with one space and no quotes.
  5. Is it a cross-origin call? The server must list Authorization in Access-Control-Allow-Headers, or the browser won't send it.

Debugging a 403

  1. Which rule refused you? Check the application logs, or the response body if the API is helpful.
  2. Did the request even reach your app? WAFs and CDNs (Cloudflare, AWS WAF) return 403 for blocked requests, often with their own error page. If the response carries the CDN's branding or headers such as cf-ray, your code never saw it.
  3. For static files, check permissions. nginx and Apache answer 403 when they can't read a file or enter a directory — the usual fix is 755 for directories and 644 for files.
  4. CSRF. Django, for example, rejects a form POST without a valid CSRF token with 403.
  5. Signed URLs. An expired S3 presigned URL returns 403 Access Denied.

Which to return from your API

  • Missing or invalid credentials → 401, with WWW-Authenticate.
  • Valid credentials, insufficient permissions → 403, with a body naming the permission.
  • A resource whose existence is sensitive → 404 for users who can't see it.
  • Too many requests → neither: that's 429 Too Many Requests.

Consistency matters more than any single choice: clients build their retry and login logic around what you return. For every other status code, see the HTTP status code reference.

More from the blog