HTTP status code · Client errors (4xx)
401 Unauthorized
401 Unauthorized means the server did not accept who you are: the request carried no credentials, or the ones it carried are invalid or expired. Despite the name, it is about authentication, and the most common cause is an expired session or access token.
| Class | 4xx, Client errors |
|---|---|
| Defined in | RFC 9110 §15.5.2 |
| Cacheable by default | Only with explicit Cache-Control or Expires |
| Safe to retry | Yes, with new or valid credentials in the Authorization header |
| Relevant headers |
|
What 401 means
RFC 9110, section 15.5.2, defines 401 as the request lacking valid authentication credentials for the target resource. The name "Unauthorized" is a historical slip: the code is about identity (authentication), while refusing someone the server already knows is 403.
A server sending 401 must include a WWW-Authenticate header with at least one challenge, for example Basic realm="admin" or Bearer realm="api". The challenge tells the client how to authenticate. With Basic, browsers react by showing their built-in login dialog; with Bearer, OAuth 2.0 servers add error="invalid_token" (RFC 6750) to say the token was rejected rather than missing.
If the request already had credentials, the 401 means they were refused. The client may retry with a new Authorization header, which is exactly what token-refresh logic does: catch the 401, exchange the refresh token for a new access token, replay the request once.
Common causes
If you are visiting the site
- Your session expired or you logged out in another tab, so the site no longer recognizes you.
- Wrong username or password on a page protected by HTTP Basic authentication (the browser pop-up keeps coming back).
- Blocked third-party cookies when the login lives on a different domain than the page you are on.
If you run the server
- An expired JWT or OAuth access token, often with no refresh logic on the client.
- The Authorization header is malformed: missing the "Bearer " prefix, a token with stray quotes or newline, or Basic credentials not base64-encoded.
- A proxy, CDN or load balancer stripping the Authorization header before it reaches the application.
- Clock skew between servers, so a freshly issued token looks not yet valid (nbf) or already expired (exp).
- CORS preflight: the browser sends OPTIONS without credentials, and middleware that demands auth on every method answers 401, which the browser reports as a CORS failure.
How to fix it
If you are visiting the site
- Log out and log back in; this issues a fresh session.
- Clear the cookies for the site if logging in immediately bounces you back to a 401.
- For a Basic auth pop-up, check the credentials with whoever runs the site; the browser caches them until you close it.
If you run the server
- Decode the token (a JWT decoder shows exp, nbf, iss and aud) and compare the claims with what the API expects.
- Implement refresh on 401: renew the access token once, replay the request, and log the user out if the refresh also fails.
- Let OPTIONS requests through the auth middleware so CORS preflights succeed.
- Check that every proxy hop forwards Authorization; some setups need it passed explicitly.
- Always include WWW-Authenticate; clients and libraries rely on it, and the spec requires it.
How to send 401
nginx auth_basic sends the 401 and its WWW-Authenticate: Basic challenge for you; create the password file with htpasswd.
app.get('/me', (req, res) => {
res.set('WWW-Authenticate', 'Bearer realm="api", error="invalid_token"');
res.status(401).json({ error: 'Access token is missing or expired' });
});// app/me/route.ts
export async function GET() {
return Response.json(
{ error: 'Access token is missing or expired' },
{ status: 401, headers: { 'WWW-Authenticate': 'Bearer realm="api", error="invalid_token"' } }
);
}mux.HandleFunc("GET /me", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("WWW-Authenticate", `Bearer realm="api", error="invalid_token"`)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized) // 401
w.Write([]byte(`{"error":"Access token is missing or expired"}`))
})from fastapi import FastAPI, HTTPException
app = FastAPI()
@app.get("/me")
def get_me():
raise HTTPException(
status_code=401,
detail="Access token is missing or expired",
headers={"WWW-Authenticate": 'Bearer realm="api", error="invalid_token"'},
)# nginx answers 401 with a WWW-Authenticate: Basic challenge
location /admin/ {
auth_basic "Admin area";
auth_basic_user_file /etc/nginx/.htpasswd;
}Commonly confused with
- 401 vs 403
- 401 means "I do not know who you are, authenticate"; 403 means "I know or do not care who you are, and the answer is no".
- 401 vs 407
- 407 is the same challenge coming from a proxy between you and the server, using Proxy-Authenticate instead of WWW-Authenticate.
Frequently asked questions
- What is the difference between 401 and 403?
- 401 is about authentication: the server needs valid credentials and a new login may fix it. 403 is about authorization: the server knows who you are, or does not need to, and still refuses, so logging in again will not help.
- Why do I get 401 with a token that worked a minute ago?
- Access tokens are short-lived, often 5 to 60 minutes. Decode the JWT and check the exp claim. Clock skew between the issuer and the API can also make a token look expired early.
- Why does my API return 401 only from the browser?
- Usually the CORS preflight. The browser sends an OPTIONS request without the Authorization header first; if your middleware requires auth on OPTIONS, the preflight fails and the real request never goes out.
- Is it OK to send 401 without a WWW-Authenticate header?
- No. RFC 9110 says the server MUST send WWW-Authenticate with at least one challenge. For token APIs, Bearer realm="api" is enough.
Last reviewed by Arielton Oberek.