401 vs 403: Unauthorized vs Forbidden explained

401 Unauthorized means the server doesn't know who you are: credentials are missing, invalid or expired. 403 Forbidden means the server knows who you are (or doesn't care) and refuses anyway. Fix a 401 by authenticating. A 403 won't go away by sending the same credentials again; you need different permissions.

The name is misleading. 401 is really "unauthenticated", and 403 is "unauthorized".

401 Unauthorized403 Forbidden
QuestionWho are you?I know who you are. Not allowed.
CauseNo token, bad password, expired or revoked token, invalid signatureMissing scope or role, IP or WAF block, resource belongs to someone else
Will retrying with new credentials help?YesUsually no
Required headerWWW-AuthenticateNone
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="The access token expired"
Content-Type: application/json

{"error": "invalid_token"}
HTTP/1.1 403 Forbidden
Content-Type: application/json

{"error": "insufficient_scope", "message": "This key cannot write to repositories"}

Common causes of 401

  • No credentials sent. A missing Authorization header, often stripped by a proxy or lost on a redirect.
  • Wrong format. Bearer vs Basic vs Token, or a missing space.
  • Expired or revoked token. OAuth access tokens expire, often within an hour.
  • Wrong environment. A test key against the live API, or a staging token against production.

Common causes of 403

  • Missing scope or role. The token works but wasn't granted this permission.
  • IP, country or WAF rules. Cloudflare, AWS WAF or an nginx deny rule blocks the request before your code sees it. Cloudflare's block page says "Sorry, you have been blocked".
  • File permissions. nginx and Apache return 403 when they can't read a file, or when a directory has no index file and listing is off.
  • Ownership. The resource exists but belongs to another account. Some APIs return 404 here instead, to avoid revealing that it exists.
  • CSRF failures in frameworks like Django and Laravel.

How to fix them

As a user: for 401, log in again. For 403, check you're signed into the right account and ask an admin for access.

As a developer:

  1. Print the exact request with curl -v and confirm the Authorization header arrives. Proxies and redirects drop it more often than you'd think.
  2. For 401, decode the token (JWTs are readable at the payload level) and check exp, aud and iss.
  3. For 403, check whether the response came from your app or from a CDN or WAF in front of it. The Server header and the body usually tell you.
  4. On nginx, a 403 for static files is usually file permissions or a missing index. Check the error log for permission denied or directory index ... is forbidden.

401 and 403 on a webhook endpoint

Webhooks rarely carry login credentials. Instead, the provider signs the payload with a shared webhook secret, and your endpoint checks the webhook signature. When that check fails, most handlers return 401. Some providers' examples use 400. Either way, a 4xx tells the provider the request was rejected.

When signatures fail, the cause is rarely an attacker. It's usually:

  • The wrong secret. Test vs live, or a secret from a different endpoint. Each endpoint usually has its own.
  • A modified body. The signature is computed over the raw bytes. If your framework parses the JSON and you re-serialise it, the bytes change and the HMAC no longer matches.
  • A stale timestamp. Providers like Stripe include a timestamp, and your code rejects it if it's outside a tolerance window. Check the server clock.
  • The wrong header or encoding. Hex vs base64, or a sha256= prefix that wasn't stripped.

Paste the exact payload, secret and header into the webhook signature verifier or the HMAC tool to see what the signature should be. To reject unsigned requests before they reach your app, a Webhook Relay function can verify the HMAC at the edge, and buckets also support username/password and token auth.

Return 403 for webhooks only for a policy decision, for example a request from an IP outside the provider's published range.

When to return 401 vs 403 from your own API

  • 401 when credentials are missing, malformed, expired or don't verify. Always include WWW-Authenticate.
  • 403 when the caller is authenticated but not permitted, or when a policy (IP allowlist, plan, ownership) blocks the request.
  • 404 instead of 403 when even confirming the resource exists would leak information.

Frequently asked questions

What is the difference between 401 and 403?

401 Unauthorized means the request has no valid credentials: the server doesn't know who you are, so log in or send a valid token and try again. 403 Forbidden means the server knows who you are, or doesn't need to, and refuses anyway: your account or key isn't allowed to do this, and sending the same credentials again won't help.

Should I return 401 or 403 for an expired token?

401. An expired, malformed or revoked token means the client isn't authenticated. Return 401 with a WWW-Authenticate header so the client knows to refresh the token or log in again. Return 403 only when a valid token lacks permission.

Why do I get 403 Forbidden with a valid API key?

The key is recognised but not allowed to do this. Common reasons are a missing scope or permission, a key restricted to certain IPs or domains, a resource owned by another account, or a WAF or CDN rule blocking the request before it reaches the app.

Should a webhook endpoint return 401 or 403 for a bad signature?

Either is defensible, and 401 is the most common choice; some providers' examples use 400. What matters is returning a 4xx, not a 2xx, and logging why. A bad signature usually means the wrong secret or a body that was parsed and re-serialised before verification, not an attack.

Does 401 Unauthorized mean not authenticated?

Yes. Despite the name, 401 is about authentication, not authorization. The HTTP spec says a 401 response must include a WWW-Authenticate header describing how to authenticate. 403 is the code for an authorization failure.