# 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](/http/401) is really about *authentication* — who you are. [403 Forbidden](/http/403) 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
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
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](/http/404) 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](/tools/security/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](/chmod/755) and [644 for files](/chmod/644).
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](/http/429).

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](/http).
