Troubleshooting

401 Unauthorized: what the server wants, and why your credentials don't arrive

A 401 means the server wants valid credentials and got none. Read WWW-Authenticate with curl -v, fix nginx auth_basic and Bearer tokens, tell 401 from 403.

Published ~8 min read

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.

see what you sent and what the server wants
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.

Question401 Unauthorized403 Forbidden
What it saysWho are you? No valid credentialsI know who you are, or it does not matter: no
Required headerWWW-Authenticate with the expected schemeNone
Does logging in again help?Yes, with valid credentialsNo: you need other rights, another account or a server change
Typical nginx causeauth_basic without a valid user and passworddeny rules, file permissions, a missing index file
OAuth Bearer APIs (RFC 6750)invalid_token: expired, revoked or malformedinsufficient_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?

common challenges
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. An insufficient_scope error belongs with a 403 and invalid_request with 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

  1. 1No usable credentials reached the server

    In the curl -v output, 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 $ literal

    The usual slips: the wrong scheme word (Bearer, Token and Bot are not interchangeable; Discord bot tokens go in Authorization: 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 with echo fails 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
  2. 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.

  3. 3The credentials were stripped on the way
    • A redirect dropped them. curl sends -u, --oauth2-bearer and a hand-set Authorization header only to the origin you called; a redirect that changes the host, the scheme or the port drops them. So curl -L -u alice:… http://example.com/api that is redirected to https:// arrives without credentials and gets a 401. Browsers’ fetch() drops Authorization on cross-origin redirects too. Call the final https:// URL directly; use --location-trusted only when you trust where the redirect goes.
    • nginx dropped a header with an underscore. A custom header such as X_API_KEY is discarded by default (underscores_in_headers off), so the application never sees it and answers 401. Rename it to X-API-Key.
    • Apache did not pass it to PHP. With PHP-FPM or CGI, Apache keeps the Authorization header from scripts unless you set CGIPassAuth On (Apache 2.4.13 and later) in the <Directory> block or in .htaccess.
    • Two layers want the same header. A request carries one Authorization header. With nginx auth_basic in 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_header in a location replaces all the ones it would otherwise inherit from the server block, so repeat your Host and X-Forwarded-* lines there.

  4. 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

    -B stores 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 -B to 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 nginx

    Browsers 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.

  5. 5The browser reports a CORS error, and OPTIONS got the 401

    Before a cross-origin request with an Authorization header, the browser sends a preflight OPTIONS request. The preflight never carries credentials and must get a 2xx answer. If your server demands authentication on OPTIONS, the preflight gets a 401, the browser blocks the real request, and the console only says “CORS”. Let OPTIONS through 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:

Telegram Bot API
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-auth jail, 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.

Related articles

All troubleshooting