How It Works Features Pricing Blog Error Guides
Log In Start Free Trial
FastAPI · Python

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

Traceback (most recent call last): File "/app/venv/lib/python3.11/site-packages/uvicorn/protocols/http/h11_impl.py", line 406, in run_asgi result = await app(scope, receive, send) File "/app/venv/lib/python3.11/site-packages/starlette/routing.py", line 677, in __call__ await route.handle(scope, receive, send) File "/app/venv/lib/python3.11/site-packages/fastapi/routing.py", line 234, in app raw_response = await run_endpoint_function(dependant=dependant, values=values) File "/app/src/auth/jwt_handler.py", line 28, in verify_token payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) File "/app/venv/lib/python3.11/site-packages/jose/jwt.py", line 152, in decode _validate_claims(payload, options=defaults) File "/app/venv/lib/python3.11/site-packages/jose/jwt.py", line 286, in _validate_claims _validate_exp(payload, leeway=leeway) jose.ExpiredSignatureError: Signature has expired

Here's what each line means:

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.

Before (broken)
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")
After (fixed)
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:

  1. Notice the error alert or see it in your monitoring tool
  2. Open the error dashboard and read the stack trace
  3. Identify the file and line number from the stack trace
  4. Open your IDE and navigate to the file
  5. Read the surrounding code to understand context
  6. Reproduce the error locally
  7. Identify the root cause
  8. Write the fix
  9. Run the test suite locally
  10. Fix any failing tests
  11. Write new tests covering the edge case
  12. Run the full test suite again
  13. Create a new git branch
  14. Commit and push your changes
  15. Open a pull request
  16. Wait for code review
  17. Merge and deploy to production
  18. 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:

  1. Captures the stack trace and request context
  2. Pulls the relevant source files from your GitHub repo
  3. Analyzes the error and understands the code context
  4. Generates a minimal, verified fix
  5. Runs your existing test suite
  6. Pushes through your CI/CD pipeline
  7. 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)

  1. Run the test suite locally to confirm JWT validation handles all error cases.
  2. Open a pull request with the improved token validation.
  3. Wait for CI checks to pass on the PR.
  4. Have a teammate review and approve the PR.
  5. 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.