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 Unauthorized | 403 Forbidden | |
|---|---|---|
| Question | Who are you? | I know who you are. Not allowed. |
| Cause | No token, bad password, expired or revoked token, invalid signature | Missing scope or role, IP or WAF block, resource belongs to someone else |
| Will retrying with new credentials help? | Yes | Usually no |
| Required header | WWW-Authenticate | None |
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
Authorizationheader, often stripped by a proxy or lost on a redirect. - Wrong format.
BearervsBasicvsToken, 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
denyrule 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:
- Print the exact request with
curl -vand confirm theAuthorizationheader arrives. Proxies and redirects drop it more often than you'd think. - For 401, decode the token (JWTs are readable at the payload level) and check
exp,audandiss. - For 403, check whether the response came from your app or from a CDN or WAF in front of it. The
Serverheader and the body usually tell you. - On nginx, a 403 for static files is usually file permissions or a missing
index. Check the error log forpermission deniedordirectory 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.
