401 Unauthorized - 10 Causes and How to Fix the HTTP 401 Error

Your request came back with 401 Unauthorized and nothing in the response body tells you why. Despite the name, 401 Unauthorized is not about permissions - it means the server could not authenticate you at all: the credentials were missing, malformed, expired, or meant for a different audience.

That distinction matters because it cuts your search space in half. If you had been authenticated but lacked permission, you would be looking at 403 Forbidden instead. A 401 says the server does not know who you are.

This page walks the ten causes that account for nearly every 401 in practice, most common first, each with a reproduction you can run and the exact lines that fix it. If your case does not match any of them, skip to the diagnostic checklist - it narrows an unknown 401 down in five steps. Person working on programming code on a laptop indoors. Glasses on the table.

ErrorHTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer realm="api", error="invalid_token"
Where it happensAny HTTP client against any server - curl, fetch, axios, requests, Postman - and any API gateway, reverse proxy, or auth middleware in front of the application.
What it meansThe server rejected the request because it could not verify who sent it - no credentials, or credentials it could not validate.

The Fast Fix

Nine times out of ten the Authorization header is missing, malformed, or carrying an expired token. Send it explicitly with the correct scheme and confirm the server sees it:

# Note the space after Bearer - without it the header is unparseable
curl -i -H "Authorization: Bearer $ACCESS_TOKEN" https://api.example.com/v1/me

If that still returns 401, read the WWW-Authenticate response header. A compliant server puts the reason there: error="invalid_token" means expired or malformed, error="invalid_request" means the header itself is wrong, and no header at all usually means a proxy rejected you before the application ran.

If you get a 401 only from the browser and not from curl, jump to cause 7 - it is almost certainly credentials not being sent cross-origin.

What Is Actually Causing It

1. No Authorization header sent at all

Reproduce it

curl -i https://api.example.com/v1/me
# HTTP/1.1 401 Unauthorized
# WWW-Authenticate: Bearer realm="api"
// The token was fetched but never attached
const token = await getAccessToken();
const res = await fetch("https://api.example.com/v1/me");
console.log(res.status); // 401

Why it happens — The endpoint requires authentication and the request arrived anonymous. This is the single most common 401: the token exists somewhere in your code but never reaches the headers object, often because a wrapper function builds the request and silently drops anything it does not recognise.

The fix

const token = await getAccessToken();
const res = await fetch("https://api.example.com/v1/me", {
  headers: { Authorization: `Bearer ${token}` },
});
console.log(res.status); // 200

What changed: the headers option is now passed, with the token under the Authorization key.

Confirm it worked — Log the outgoing request before it is sent - console.log([...new Headers(init.headers)]) - or open the browser DevTools Network tab, click the request, and confirm Authorization appears under Request Headers. If it is absent there, the problem is client-side, not server-side.


2. The access token has expired

Reproduce it

# A token minted an hour ago against a 15-minute TTL
curl -i -H "Authorization: Bearer $OLD_TOKEN" https://api.example.com/v1/me
# HTTP/1.1 401 Unauthorized
# WWW-Authenticate: Bearer error="invalid_token", error_description="token expired"

Why it happens — Access tokens carry an exp claim and servers reject anything past it, usually with a few seconds of clock skew tolerance. Long-lived sessions and cached clients hit this constantly: the token worked when the page loaded and stopped working while the user was reading.

The fix

// Refresh on 401 once, then retry the original request
async function authedFetch(url, init = {}) {
  let token = await getAccessToken();
  let res = await fetch(url, {
    ...init,
    headers: { ...init.headers, Authorization: `Bearer ${token}` },
  });

  if (res.status === 401) {
    token = await refreshAccessToken(); // exchange the refresh token
    res = await fetch(url, {
      ...init,
      headers: { ...init.headers, Authorization: `Bearer ${token}` },
    });
  }
  return res;
}

What changed: a single retry after refreshing. Retry exactly once - a loop here will hammer your auth server when the refresh token is also dead.

Confirm it worked — Decode the payload and compare exp against now: node -e "console.log(new Date(JSON.parse(Buffer.from(process.argv[1].split('.')[1],'base64url')).exp*1000))" "$TOKEN". If that timestamp is in the past, expiry was your cause.


3. Wrong authentication scheme in the header

Reproduce it

# Server expects Bearer, client sends Basic
curl -i -H "Authorization: Basic $ACCESS_TOKEN" https://api.example.com/v1/me
# HTTP/1.1 401 Unauthorized

# Or no scheme at all - just the raw token
curl -i -H "Authorization: $ACCESS_TOKEN" https://api.example.com/v1/me
# HTTP/1.1 401 Unauthorized

Why it happens — The Authorization header is <scheme> <credentials>. Middleware splits on the first space and compares the scheme case-insensitively against what it expects. A raw token with no scheme, or Basic where Bearer was expected, fails the split before the credentials are ever examined.

The fix

curl -i -H "Authorization: Bearer $ACCESS_TOKEN" https://api.example.com/v1/me
# HTTP/1.1 200 OK

What changed: the scheme is now Bearer, with exactly one space before the token. Check the API docs for which scheme the endpoint wants - some APIs use Token, ApiKey, or a custom scheme instead.

Confirm it worked — Request an endpoint with no credentials and read the WWW-Authenticate response header. The scheme named there (Bearer, Basic, Digest) is the one the server will accept.


4. Credentials sent in the wrong place - header vs query vs custom key

Reproduce it

# API key belongs in a custom header, sent as a query param instead
curl -i "https://api.example.com/v1/items?api_key=$API_KEY"
# HTTP/1.1 401 Unauthorized
import requests

# params instead of headers
r = requests.get("https://api.example.com/v1/items", params={"api_key": API_KEY})
print(r.status_code)  # 401

Why it happens — Many APIs accept a key only in one specific location - a custom header like X-API-Key, or Authorization, but not a query string. The server never looks at your parameter, sees no credentials, and returns 401. Query-string keys are also widely deprecated because they leak into access logs.

The fix

import requests

r = requests.get(
    "https://api.example.com/v1/items",
    headers={"X-API-Key": API_KEY},
)
print(r.status_code)  # 200

What changed: the key moved from params into headers under the exact name the API documents. The header name must match character for character; values are case-sensitive even though names are not.

Confirm it worked — Compare your request against the API's own documented curl example side by side. If the docs show -H "X-API-Key: ..." and you are sending ?api_key=, that is the cause.


5. Token issued for a different audience, issuer, or environment

Reproduce it

# Token minted against the staging tenant, sent to production
curl -i -H "Authorization: Bearer $STAGING_TOKEN" https://api.example.com/v1/me
# HTTP/1.1 401 Unauthorized
# WWW-Authenticate: Bearer error="invalid_token", error_description="audience mismatch"

Why it happens — A JWT carries iss (who minted it) and aud (who it is for). The resource server verifies both and rejects a structurally valid, unexpired token whose claims point elsewhere. This is the classic failure after copying a .env between environments - the token is real, just not for this server.

The fix

# Request the token for the audience you are actually calling
curl -s -X POST https://auth.example.com/oauth/token \
  -d grant_type=client_credentials \
  -d client_id="$CLIENT_ID" \
  -d client_secret="$CLIENT_SECRET" \
  -d audience="https://api.example.com/v1/"

What changed: the audience parameter now names the production API, so the minted token's aud claim matches what the server verifies.

Confirm it worked — Decode the payload and read iss and aud: echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool. Both must match the server's configured issuer and audience exactly, including the trailing slash.


6. Signature verification fails - wrong key or algorithm

Reproduce it

import jwt  # PyJWT

# Signed with one secret, verified with another
token = jwt.encode({"sub": "u1"}, "dev-secret", algorithm="HS256")
jwt.decode(token, "prod-secret", algorithms=["HS256"])
# jwt.exceptions.InvalidSignatureError: Signature verification failed
# -> the API layer turns this into 401 Unauthorized

Why it happens — The server recomputes the signature over the header and payload and compares it to the one attached. A rotated secret, a mismatched key pair, or a token signed with HS256 while the server expects RS256 all produce a verification failure, which auth middleware correctly reports as 401 rather than 500.

The fix

import jwt

SECRET = "prod-secret"

# Sign and verify with the same key and the same algorithm
token = jwt.encode({"sub": "u1"}, SECRET, algorithm="HS256")
claims = jwt.decode(token, SECRET, algorithms=["HS256"])
print(claims["sub"])  # u1

What changed: one shared SECRET on both sides and a matching algorithm. For asymmetric setups, the server verifies with the public key corresponding to the private key that signed - commonly fetched from the issuer's JWKS endpoint.

Confirm it worked — Read the alg field in the token header (echo "$TOKEN" | cut -d. -f1 | base64 -d) and confirm it is in the server's allowed algorithm list. Then confirm the key ID (kid) exists in the issuer's JWKS document.


More causes (6 remaining)

7. Browser not sending cookies or credentials cross-origin

Reproduce it

// Session cookie exists, but fetch omits it for a cross-origin call
const res = await fetch("https://api.example.com/v1/me");
console.log(res.status); // 401 - no cookie was attached

Why it happens — fetch defaults to credentials: "same-origin", so cookies are not sent to a different origin. The browser issues the request anonymously and the server, seeing no session, returns 401. This is why the same call succeeds from curl - curl has no same-origin policy.

The fix

const res = await fetch("https://api.example.com/v1/me", {
  credentials: "include",
});
console.log(res.status); // 200

What changed: credentials: "include" tells the browser to attach cookies cross-origin. The server must cooperate - it needs Access-Control-Allow-Credentials: true and an explicit origin in Access-Control-Allow-Origin (a wildcard * is rejected with credentials), and the cookie itself needs SameSite=None; Secure.

Confirm it worked — In DevTools Network, select the request and check Request Headers for Cookie. If it is missing there but present under Application > Cookies, the browser withheld it - the client or cookie attributes are the cause, not the server.


8. Auth middleware applied to the CORS preflight request

Reproduce it

// Express - auth runs before CORS, so the OPTIONS preflight gets 401
app.use(requireAuth);
app.use(cors({ origin: "https://app.example.com", credentials: true }));
curl -i -X OPTIONS https://api.example.com/v1/me \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: GET" \
  -H "Access-Control-Request-Headers: authorization"
# HTTP/1.1 401 Unauthorized

Why it happens — Browsers send a preflight OPTIONS request before any cross-origin call carrying an Authorization header - and by specification that preflight carries no credentials. If your auth middleware sits in front of CORS, it rejects the preflight with 401 and the real request is never sent.

The fix

// CORS first, so preflights are answered before auth runs
app.use(cors({ origin: "https://app.example.com", credentials: true }));
app.use(requireAuth);

What changed: the middleware order. The cors middleware short-circuits OPTIONS with a 204 before requireAuth ever sees it.

Confirm it worked — Run the curl -X OPTIONS above. A healthy preflight returns 204 or 200 with Access-Control-Allow-Origin and Access-Control-Allow-Headers: authorization in the response. A 401 on OPTIONS confirms this cause.


9. A proxy, gateway, or CDN rejects the request before your app

Reproduce it

curl -i https://api.example.com/v1/me
# HTTP/1.1 401 Unauthorized
# WWW-Authenticate: Basic realm="Restricted"
# Server: nginx
# <html><head><title>401 Authorization Required</title></head>...

Why it happens — An HTML error body, a Basic realm challenge you never configured, or a Server header naming nginx or a CDN means the 401 came from infrastructure, not your application. Basic-auth-protected staging environments and API gateways with their own key checks both do this - your application logs will show no request at all.

The fix

# Satisfy the proxy's own Basic auth, separately from the API token
curl -i -u "$PROXY_USER:$PROXY_PASS" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  https://api.example.com/v1/me

Note that -u and -H "Authorization: ..." both target the same header - curl's -H wins. When two layers each want credentials, the proxy layer must use a different header (such as Proxy-Authorization) or be configured to pass yours through untouched.

What changed: the proxy's credentials are supplied, or - the better fix - the gateway is configured to forward the Authorization header to the upstream instead of consuming it.

Confirm it worked — Check whether the request reached your application at all: tail your access logs while sending it. No log line means a layer in front rejected it. Compare the Server header and response body format against a 401 you know your app produces.


10. Malformed header value - stray whitespace, quotes, or newline

Reproduce it

# Token read from a file keeps its trailing newline
TOKEN=$(cat token.txt)
curl -i -H "Authorization: Bearer $TOKEN" https://api.example.com/v1/me
# HTTP/1.1 401 Unauthorized
# Or quotes copied in from JSON
curl -i -H 'Authorization: Bearer "eyJhbGciOi..."' https://api.example.com/v1/me
# HTTP/1.1 401 Unauthorized

Why it happens — Header values are compared byte for byte after the scheme. A trailing newline from cat, surrounding double quotes copied out of a JSON response, or a line break injected by a terminal wrapping a long token all make the credentials unparseable. The token is valid; the header carrying it is not.

The fix

# Strip trailing whitespace at read time
TOKEN=$(tr -d '\n\r' < token.txt)
curl -i -H "Authorization: Bearer $TOKEN" https://api.example.com/v1/me
# HTTP/1.1 200 OK

What changed: tr -d '\n\r' removes the newline before the value is interpolated. In application code, call .trim() (JS) or .strip() (Python) on any token read from a file or environment variable.

Confirm it worked — Print the value with delimiters so invisible characters show: printf '[%s]\n' "$TOKEN". The closing bracket must sit immediately after the last token character, on the same line. A JWT should also contain exactly two dots.


11. Token revoked, session invalidated, or credentials rotated

Reproduce it

# Worked five minutes ago; the user then logged out everywhere
curl -i -H "Authorization: Bearer $ACCESS_TOKEN" https://api.example.com/v1/me
# HTTP/1.1 401 Unauthorized
# WWW-Authenticate: Bearer error="invalid_token", error_description="token revoked"

Why it happens — An unexpired token can still be rejected. A password change, an explicit logout, a revoked API key, or a rotated signing secret invalidates credentials server-side while the exp claim still looks fine. Your client has no way to know locally - the first signal is the 401.

The fix

// Treat a post-refresh 401 as "re-authenticate", not "retry"
if (res.status === 401) {
  const refreshed = await tryRefresh();
  if (!refreshed) {
    clearStoredTokens();
    redirectToLogin(); // do not retry - the credentials are gone for good
  }
}

What changed: a failed refresh now clears local state and sends the user to login, instead of retrying with credentials the server has already thrown away.

Confirm it worked — Mint a fresh token with the same client credentials and call the endpoint. If the new token succeeds and the old one still 401s, the old one was revoked rather than misconfigured.


12. Basic auth credentials encoded incorrectly

Reproduce it

import base64, requests

# Encoding the pair without the colon separator
raw = base64.b64encode(b"alice" + b"s3cret").decode()
r = requests.get(
    "https://api.example.com/v1/me",
    headers={"Authorization": f"Basic {raw}"},
)
print(r.status_code)  # 401

Why it happens — Basic auth expects base64 of username:password - the colon is part of the payload, and the value must be base64, not URL-encoded or plain text. Omitting the colon, or sending the pair unencoded, means the server decodes garbage and finds no matching user.

The fix

import requests

# Let the library build the header - it handles the colon and encoding
r = requests.get(
    "https://api.example.com/v1/me",
    auth=("alice", "s3cret"),
)
print(r.status_code)  # 200

What changed: auth=(user, password) replaces the hand-rolled header. If you must build it yourself, encode f"{user}:{password}" - with the colon - and note that a password containing a colon still works, since only the first colon separates the two fields.

Confirm it worked — Decode what you are sending and check the shape: echo "$B64" | base64 -d must print username:password with exactly one colon between them.

Numerous wires and cables mounted into server patch panel in modern data center ## None of Those? Narrow It Down
  1. Read the WWW-Authenticate response header first: curl -i <url> | grep -i www-authenticate. It names the scheme the server wants and, for OAuth servers, an error= code - invalid_token points at expiry, signature, or audience (causes 2, 5, 6, 11); invalid_request points at a malformed header (causes 3, 10). No such header at all suggests a non-compliant app or an infrastructure layer.
  2. Check whether the request reached your application: tail the app's access log while you send it. A 401 with no corresponding log line means a proxy, gateway, or CDN rejected it - cause 9 - and nothing about your token is relevant yet. An HTML error body or a Server: nginx header is the same signal.
  3. Reproduce with curl from your own machine. Success in curl but failure from the browser isolates it to browser-only behaviour: credentials not sent cross-origin (cause 7) or a preflight being rejected (cause 8). Failure in both rules the browser out entirely.
  4. Confirm the header actually leaves the client: print it with delimiters (printf '[%s]\n' "$HEADER") or inspect Request Headers in DevTools. Absent means cause 1; present but with stray quotes, a trailing newline, or a missing space after the scheme means cause 10.
  5. Decode the token payload and inspect exp, iss, and aud: echo "$TOKEN" | cut -d. -f2 | base64 -d. A past exp is cause 2; a mismatched iss or aud is cause 5. If it will not decode or has no two dots, you are not holding a JWT - recheck where the value came from.
  6. Mint a brand-new token with the same client credentials and retry. If the fresh token succeeds, the old one was expired or revoked (causes 2, 11). If the fresh token also fails, the problem is in how the request is built or how the server is configured, not in the token's lifetime.
  7. Test an endpoint on the same API that you know requires no authentication. A 200 there with a 401 on your target means authentication is genuinely the issue; a 401 even on the public endpoint points to a blanket middleware or gateway rule applied too broadly.
  8. Compare against the API's own documented curl example line by line - scheme name, header name, and credential location. A key the docs put in X-API-Key and you put in a query string is cause 4, and it will survive every other check above because your credentials are perfectly valid, just invisible to the server.

Why This Error Exists At All

HTTP needs to distinguish two failures that feel identical to a user but are completely different to a server: I do not know who you are and I know who you are and you may not do this. 401 Unauthorized is the first; 403 Forbidden is the second. The status code is misnamed - RFC 7235 says plainly that 401 means "unauthenticated" - but the split is deliberate, because only one of the two is worth retrying with different credentials.

That is why 401 is defined as a challenge, not merely a rejection. A compliant server must include a WWW-Authenticate header naming the scheme it accepts, and the client is expected to retry with credentials in that scheme. This is the whole mechanism behind browser Basic-auth popups and behind OAuth refresh flows: the 401 is the server's half of a negotiation, saying "try again, and here is how." A 403 carries no such invitation - retrying is pointless. Once you internalise this, the response header stops being noise and becomes the first thing you read.

The design also explains why 401 shows up in places that have nothing to do with your token. HTTP authentication is layered and stateless: every request must carry its own credentials, and any hop that inspects requests - a CDN, an API gateway, an nginx auth_basic block, your framework's middleware - can issue the challenge independently. Nobody downstream knows that an upstream already rejected you. So a 401 is not one error with ten variants; it is one answer that ten different participants can give to the same question, "can you prove who you are?" Your debugging job is almost always to find out which layer asked before you worry about what to answer. The same reasoning covers the rest of the 4xx authentication family - 407 Proxy Authentication Required is the identical challenge from a proxy, and 419 or 440 session-expiry codes some frameworks emit are non-standard shorthands for the same idea. Hand holding a brass padlock, symbolizing security and protection

Stop It From Coming Back

  • Centralise authentication in one HTTP client wrapper - an axios interceptor, a requests.Session with headers set once, or a single authedFetch - so no call site can forget the Authorization header. This eliminates cause 1 structurally rather than by discipline.
  • Refresh proactively against the token's exp claim instead of reactively on 401: schedule a refresh at 75% of the token lifetime and keep the on-401 retry as a fallback only. Add a mutex around the refresh so concurrent requests trigger one token exchange, not ten.
  • Trim and validate credentials where they enter the process - process.env.TOKEN?.trim() or os.environ["TOKEN"].strip() - and fail fast at startup if a required value is missing or does not have the expected shape (a JWT has exactly two dots). This turns cause 10 into a boot-time error instead of a runtime 401.
  • Mount CORS middleware before any auth middleware and add a contract test that asserts OPTIONS on a protected route returns 204 with Access-Control-Allow-Headers: authorization. Middleware order is easy to break during a refactor and this test catches it in CI.
  • Write an integration test per environment that mints a real token and calls a protected endpoint, asserting 200. Run it after every deploy - it catches rotated secrets, audience mismatches, and gateway misconfiguration (causes 5, 6, 9) before users do.
  • Log the WWW-Authenticate header and the error code on every 401 your client receives, and never log the token itself. Without that one field, a production 401 is unfalsifiable; with it, most incidents are diagnosed from the log line alone.

Related Guides

Errors You Will Probably Hit Next

  • 403 Forbidden - authenticated successfully but not permitted to access the resource
  • 407 Proxy Authentication Required - the same challenge, issued by an intermediate proxy rather than the origin server
  • JsonWebTokenError: invalid signature / TokenExpiredError: jwt expired - the library-level exceptions that auth middleware converts into a 401
  • CORS error: Response to preflight request doesn't pass access control check - what a 401 on an OPTIONS preflight looks like from inside the browser

A 401 is a question about identity, not permission - so stop looking at what the endpoint does and start looking at what your request carried. Read WWW-Authenticate first, then confirm the request actually reached your application, then inspect the credentials themselves; that order eliminates whole categories in one step each. And when the same call works from curl but not the browser, the token was never the problem.

댓글

이 블로그의 인기 게시물

TypeError: Cannot read properties of undefined (reading 'map') - 11 Causes and Fixes

psql: FATAL: password authentication failed for user - 10 Causes and Fixes

Error: ERR_MODULE_NOT_FOUND: Cannot find package - 10 Causes and Fixes