Fix HTTPException: 401 Unauthorized: Could not validate credentials in FastAPI
This error occurs when JWT token validation fails due to an expired token, wrong signing algorithm, mismatched secret key, or malformed token header. Fix it by verifying the secret key and algorithm match between token creation and validation, adding proper error handling for expired tokens, and returning clear error messages to help the client refresh credentials.
Reading the Stack Trace
Here's what each line means:
- File "/app/src/auth/jwt_handler.py", line 28, in verify_token: The verify_token function attempts to decode the JWT but the token's expiration time has passed.
- File "/app/venv/lib/python3.11/site-packages/jose/jwt.py", line 286, in _validate_claims: The python-jose library validates standard JWT claims including expiration, and raises when 'exp' is in the past.
- jose.ExpiredSignatureError: Signature has expired: The JWT token has expired. The server should catch this specific error and return a 401 with a message indicating the client should refresh the token.
Common Causes
1. Not catching specific JWT exceptions
The verify_token function catches only generic exceptions, so expired tokens, invalid signatures, and decode errors all produce the same unhelpful message.
from jose import jwt, JWTError
def verify_token(token: str):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError:
raise HTTPException(status_code=401, detail="Could not validate credentials")
2. Mismatched algorithm between encode and decode
The token is signed with one algorithm but the decoder expects a different one, causing validation to fail silently.
# Token creation
def create_token(data: dict):
return jwt.encode(data, SECRET_KEY, algorithm="HS256")
# Token verification
def verify_token(token: str):
payload = jwt.decode(token, SECRET_KEY, algorithms=["RS256"]) # Wrong algorithm
return payload
3. Token expiration set too short
The access token expires in minutes but there is no refresh token mechanism, forcing users to re-authenticate constantly.
def create_access_token(data: dict):
expire = datetime.utcnow() + timedelta(minutes=5)
to_encode = data.copy()
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
The Fix
Catch ExpiredSignatureError separately from JWTError to return a specific error message for expired tokens, allowing the client to trigger a token refresh. Validate the 'sub' claim exists in the payload. Include the WWW-Authenticate header in 401 responses as required by the HTTP specification.
from jose import jwt, JWTError
from fastapi import HTTPException
def verify_token(token: str):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError:
raise HTTPException(status_code=401, detail="Could not validate credentials")
from jose import jwt, JWTError, ExpiredSignatureError
from fastapi import HTTPException
def verify_token(token: str):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise HTTPException(status_code=401, detail="Token missing subject claim")
return payload
except ExpiredSignatureError:
raise HTTPException(
status_code=401,
detail="Token has expired",
headers={"WWW-Authenticate": "Bearer"},
)
except JWTError:
raise HTTPException(
status_code=401,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
Testing the Fix
import pytest
from datetime import datetime, timedelta
from jose import jwt
from fastapi.testclient import TestClient
from app.main import app
from app.auth.jwt_handler import SECRET_KEY, ALGORITHM
client = TestClient(app)
def _make_token(exp_delta: timedelta, sub: str = "testuser"):
payload = {"sub": sub, "exp": datetime.utcnow() + exp_delta}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def test_valid_token_returns_200():
token = _make_token(timedelta(hours=1))
response = client.get(
"/users/me",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200
def test_expired_token_returns_401():
token = _make_token(timedelta(hours=-1))
response = client.get(
"/users/me",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 401
assert "expired" in response.json()["detail"].lower()
def test_invalid_token_returns_401():
response = client.get(
"/users/me",
headers={"Authorization": "Bearer invalid.token.here"},
)
assert response.status_code == 401
def test_missing_auth_header_returns_401_or_403():
response = client.get("/users/me")
assert response.status_code in (401, 403)
Run your tests:
pytest tests/test_jwt_auth.py -v
Pushing Through CI/CD
git checkout -b fix/fastapi-jwt-auth,git add src/auth/jwt_handler.py tests/test_jwt_auth.py,git commit -m "fix: handle expired JWT tokens separately and return specific error messages",git push origin fix/fastapi-jwt-auth
Your CI config should look something like this:
name: CI
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- run: pip install -r requirements.txt
- run: pytest --tb=short -q
The Full Manual Process: 18 Steps
Here's every step you just went through to fix this one bug:
- Notice the error alert or see it in your monitoring tool
- Open the error dashboard and read the stack trace
- Identify the file and line number from the stack trace
- Open your IDE and navigate to the file
- Read the surrounding code to understand context
- Reproduce the error locally
- Identify the root cause
- Write the fix
- Run the test suite locally
- Fix any failing tests
- Write new tests covering the edge case
- Run the full test suite again
- Create a new git branch
- Commit and push your changes
- Open a pull request
- Wait for code review
- Merge and deploy to production
- Monitor production to confirm the error is resolved
Total time: 30-60 minutes. For one bug.
Or Let bugstack Fix It in Under 2 minutes
Every step above? bugstack does it automatically.
Step 1: Install the SDK
pip install bugstack
Step 2: Initialize
import bugstack
bugstack.init(api_key=os.environ["BUGSTACK_API_KEY"])
Step 3: There is no step 3.
bugstack handles everything from here:
- Captures the stack trace and request context
- Pulls the relevant source files from your GitHub repo
- Analyzes the error and understands the code context
- Generates a minimal, verified fix
- Runs your existing test suite
- Pushes through your CI/CD pipeline
- Deploys to production (or opens a PR for review)
Time from error to fix deployed: Under 2 minutes.
Human involvement: zero.
Try bugstack Free →No credit card. 5-minute setup. Cancel anytime.
Deploying the Fix (Manual Path)
- Run the test suite locally to confirm JWT validation handles all error cases.
- Open a pull request with the improved token validation.
- Wait for CI checks to pass on the PR.
- Have a teammate review and approve the PR.
- Merge to main and verify authentication flows work in staging.
Frequently Asked Questions
BugStack tests valid tokens, expired tokens, malformed tokens, and missing tokens against your endpoints, verifying the correct HTTP status codes and error messages for each scenario.
BugStack never pushes directly to production. Every fix goes through a pull request with full CI checks, so your team can review the authentication changes before merging.
HS256 (symmetric) is simpler and fine for single-service apps. RS256 (asymmetric) is better for microservices where multiple services need to verify tokens but only one should sign them.
Issue a short-lived access token and a long-lived refresh token. When the access token expires, the client sends the refresh token to a dedicated /refresh endpoint to get a new access token.