Short answer: 401 Unauthorized means the server wants you to authenticate and the request carried no valid credentials: none at all, the wrong kind, or ones that expired or were rejected. The response’s WWW-Authenticate header names the scheme it expects, such as Basic or Bearer. Log in again or send a fresh token in that format. If you are already authenticated and still refused, you are looking at a 403, which is a different problem.
curl -v https://example.com/admin/ 2>&1 | grep -iE '^> authorization|^< HTTP|^< www-authenticate'
# < HTTP/1.1 401 Unauthorized
# < WWW-Authenticate: Basic realm="Staff only"
#
# No "> Authorization:" line in the output? Then no credentials left your machine.What is the difference between 401 and 403?
Despite its name, 401 is about authentication (who you are) and 403 about authorization (what you may do). RFC 9110 requires every 401 to carry a WWW-Authenticate header with at least one challenge, and when the request did include credentials, a 401 says they were refused. A 403 means the server understood the request and declines it; repeating it with the same credentials will not change the answer.
| Question | 401 Unauthorized | 403 Forbidden |
|---|---|---|
| What it says | Who are you? No valid credentials | I know who you are, or it does not matter: no |
| Required header | WWW-Authenticate with the expected scheme | None |
| Does logging in again help? | Yes, with valid credentials | No: you need other rights, another account or a server change |
| Typical nginx cause | auth_basic without a valid user and password | deny rules, file permissions, a missing index file |
| OAuth Bearer APIs (RFC 6750) | invalid_token: expired, revoked or malformed | insufficient_scope: valid token, missing permission |
407 Proxy Authentication Required is the same idea for a proxy between you and the server, with a Proxy-Authenticate header. If your error is a 403, the nginx 403 Forbidden guide covers that side.
What does the WWW-Authenticate header tell you?
WWW-Authenticate: Basic realm="Staff only"
WWW-Authenticate: Bearer realm="api"
WWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="The access token expired"
WWW-Authenticate: Negotiate- Basic: user name and password, base64-encoded, in
Authorization: Basic …. This is the one that makes browsers show a login dialog. - Bearer without an error: you sent no token at all. RFC 6750 tells servers to leave out error details in that case.
- Bearer with
error="invalid_token": the token is expired, revoked or malformed. Get a new one. Aninsufficient_scopeerror belongs with a 403 andinvalid_requestwith a 400. - Negotiate or NTLM: Windows integrated authentication, as used by IIS on a domain. A first 401 followed by a retry with a token is the normal handshake, not a failure.
- No header at all: the server breaks the spec, which many APIs do. Read the response body; it usually says what is wrong.
Why do I get 401? The causes, most common first
- 1No usable credentials reached the server
In the
curl -voutput, look for the> Authorization:line. If it is missing or looks wrong, the request is at fault, not the server:sending credentials with curl echo "${#TOKEN}" # 0 means $TOKEN is empty in this shell: you send "Bearer " and nothing else curl -v -H "Authorization: Bearer $TOKEN" https://api.example.com/me curl -v --oauth2-bearer "$TOKEN" https://api.example.com/me # the same header curl -v -u 'alice:pa$$word' https://example.com/admin/ # single quotes keep $ literalThe usual slips: the wrong scheme word (
Bearer,TokenandBotare not interchangeable; Discord bot tokens go inAuthorization: Bot …), quotes or a line break pasted into the token, and a password with$inside double quotes, which the shell expands. Building a Basic header withechofails too, because the newline becomes part of the password:Basic header by hand echo 'alice:secret' | base64 # wrong: encodes a trailing newline, 401 printf '%s' 'alice:secret' | base64 # right curl -u 'alice:secret' https://example.com/admin/ # simplest: let curl build it - 2The token expired, was revoked or belongs somewhere else
API keys and tokens have lifetimes, get rotated after a leak, and are often tied to one environment: a test key against the live API, or a key from one region or account against another, gets a 401. A JWT carries its own expiry, and you can read it locally. The payload is only base64url-encoded, not encrypted, so there is no need to paste a live token into a website:
Debian · Ubuntu python3 -c 'import sys,json,base64; p=sys.argv[1].split(".")[1]; p+="="*(-len(p)%4); print(json.dumps(json.loads(base64.urlsafe_b64decode(p)), indent=2))' "$TOKEN" date -u -d @1799999999 # turn the "exp" or "nbf" number into a date timedatectl # look for "System clock synchronized: yes"The clock matters on both sides. A server whose clock is off by a few minutes sees fresh tokens as not yet valid (
nbf) or already expired (exp), so a 401 that only one machine gets often means its time sync is broken. - 3The credentials were stripped on the way
- A redirect dropped them. curl sends
-u,--oauth2-bearerand a hand-setAuthorizationheader only to the origin you called; a redirect that changes the host, the scheme or the port drops them. Socurl -L -u alice:… http://example.com/apithat is redirected tohttps://arrives without credentials and gets a 401. Browsers’fetch()dropsAuthorizationon cross-origin redirects too. Call the finalhttps://URL directly; use--location-trustedonly when you trust where the redirect goes. - nginx dropped a header with an underscore. A custom header such as
X_API_KEYis discarded by default (underscores_in_headers off), so the application never sees it and answers 401. Rename it toX-API-Key. - Apache did not pass it to PHP. With PHP-FPM or CGI, Apache keeps the
Authorizationheader from scripts unless you setCGIPassAuth On(Apache 2.4.13 and later) in the<Directory>block or in.htaccess. - Two layers want the same header. A request carries one
Authorizationheader. With nginxauth_basicin front of an app that expects its own Bearer token, it can satisfy one of them, never both. Protect the app with an IP allowlist instead, or move one of the credentials to another header.
nginx also forwards the Basic credentials to the backend by default. If the app should never see your staff password, clear the header in that location:
nginx · do not forward the Basic credentials location /internal/ { auth_basic "Staff only"; auth_basic_user_file /etc/nginx/.htpasswd; proxy_set_header Authorization ""; # an empty value removes the header proxy_pass http://127.0.0.1:3000; }A
proxy_set_headerin a location replaces all the ones it would otherwise inherit from the server block, so repeat yourHostandX-Forwarded-*lines there. - A redirect dropped them. curl sends
- 4nginx auth_basic rejects the user
The error log names the reason for every refused login:
/var/log/nginx/error.log user "alice": password mismatch user "carol" was not found in "/etc/nginx/.htpasswd" no user/password was provided for basic authentication (info level)The last line is the first 401 every browser gets before it shows the login dialog, so it is normal. Create the password file like this:
Debian · Ubuntu sudo apt install -y apache2-utils sudo htpasswd -c -B /etc/nginx/.htpasswd alice # -c only for the FIRST user: it empties an existing file sudo htpasswd -B /etc/nginx/.htpasswd bob # every further user, without -c sudo chown root:www-data /etc/nginx/.htpasswd sudo chmod 640 /etc/nginx/.htpasswd # without apache2-utils: a SHA-512 crypt hash, which nginx accepts as well sudo -v printf 'carol:%s\n' "$(openssl passwd -6)" | sudo tee -a /etc/nginx/.htpasswd-Bstores a bcrypt hash. nginx checks it through the system’s crypt library, which handled bcrypt and SHA-512 hashes with the stock nginx package in our Debian 12 test; drop-Bto get htpasswd’s default MD5-based hash if your system cannot. Then protect the site, and exempt the paths machines must reach without a password:in your server block auth_basic "Staff only"; auth_basic_user_file /etc/nginx/.htpasswd; location /.well-known/acme-challenge/ { auth_basic off; # certbot's webroot check cannot log in root /var/www/html; }apply sudo nginx -t && sudo systemctl reload nginxBrowsers cache Basic credentials for the session and keep sending the old ones after a password change, so test with a private window. Basic auth is only base64-encoded, so use it over HTTPS only; the reverse proxy and SSL guide sets that up.
- 5The browser reports a CORS error, and OPTIONS got the 401
Before a cross-origin request with an
Authorizationheader, the browser sends a preflightOPTIONSrequest. The preflight never carries credentials and must get a 2xx answer. If your server demands authentication onOPTIONS, the preflight gets a 401, the browser blocks the real request, and the console only says “CORS”. LetOPTIONSthrough without authentication:nginx · authentication for every method except OPTIONS location /api/ { limit_except OPTIONS { auth_basic "API"; auth_basic_user_file /etc/nginx/.htpasswd; } proxy_pass http://127.0.0.1:3000; # the app answers the preflight with its CORS headers }In an application, the same rule applies: run the CORS middleware before the authentication middleware.
What does 401 mean from Discord or Telegram?
For a bot, a 401 almost always means the token is wrong or was reset. Telegram answers a bad bot token like this:
curl -s https://api.telegram.org/bot<TOKEN>/getMe
# {"ok":false,"error_code":401,"description":"Unauthorized"}Discord expects Authorization: Bot <token>, and the libraries set that for you. Do not let a bot with a dead token restart in a tight loop: Discord counts 401 responses towards its limit of 10,000 invalid requests per 10 minutes and then blocks the IP for a while, which turns one bad token into a 429 Too Many Requests problem. Give the systemd unit a RestartSec=10 or more, and fix the token in its environment file. The Discord bot hosting page lists the plans for running a bot as a service like this.
How do I stop 401 errors from coming back?
- Call the final
https://URL directly; never rely on a redirect to carry credentials. - Keep tokens in an environment file readable only by the service user (
chmod 600), not in the code or the repository. - Refresh tokens before they expire, and keep the clock in sync on every machine.
- Use hyphens, never underscores, in custom header names.
- Treat a 401 as stop-and-fix in scripts. Retrying the same credentials only adds failures.
- Ban addresses that keep guessing passwords with fail2ban’s stock
nginx-http-authjail, which reads the same error-log lines shown above.
A password in front of an admin tool, such as phpMyAdmin, is the most common use of auth_basic, and every command here runs unchanged on a Debian VPS or an Ubuntu VPS.
Frequently asked
What is the difference between 401 Unauthorized and 403 Forbidden?
401 means the server does not know who you are: credentials are missing, expired or wrong, and it must say in the WWW-Authenticate header how to authenticate. 403 means it understood the request and refuses it, so logging in again with the same account will not help. You need other permissions or a change on the server.
How do I see why a request gets 401?
Run the request with 'curl -v'. Lines starting with '>' show whether an Authorization header was actually sent, and the '< WWW-Authenticate' line shows which scheme the server expects and, for Bearer tokens, often an error such as invalid_token. On an nginx server with auth_basic, the error log names the user and says 'password mismatch' or 'was not found'.
Why do I get 401 after a redirect even though my credentials are correct?
curl and browsers drop the Authorization header when a redirect leads to another host, scheme or port, for example from http:// to https://. The redirected request arrives without credentials and gets a 401. Call the final https:// URL directly instead of relying on the redirect.