# python-jose → PyJWT Migration Guide

> As of 2026, FastAPI's official tutorial and the wider ecosystem have migrated from `python-jose` to **PyJWT**. This reference documents the migration steps.

## What Changed

| Aspect | Old (python-jose) | New (PyJWT) | Reason |
|--------|-------------------|-------------|--------|
| Package | `python-jose[cryptography]` | `pyjwt[crypto]` | python-jose last updated May 2025, PyJWT actively maintained (Mar 2026) |
| Import | `from jose import jwt` | `import jwt` | PyJWT provides the `jwt` module directly |
| Encode | `jwt.encode(payload, key, algorithm=alg)` | `jwt.encode(payload, key, algorithm=alg)` | Same API, 100% compatible |
| Decode | `jwt.decode(token, key, algorithms=[alg])` | `jwt.decode(token, key, algorithms=[alg])` | Same API, 100% compatible |
| Exception | `from jose.exceptions import JWTError` | `from jwt.exceptions import InvalidTokenError` | Different exception hierarchy |
| Expiry claim | auto-added by jose | must add `"exp"` to payload yourself | PyJWT is lower-level — you pass a dict and must manage exp yourself |

## Migration Steps

### 1. Update pyproject.toml

```toml
# Remove
"python-jose[cryptography]>=3.3.0",

# Add
"pyjwt[crypto]>=2.12.0",
```

### 2. Update imports

```python
# Old
from jose import jwt
from jose.exceptions import JWTError

# New
import jwt
from jwt.exceptions import InvalidTokenError
```

### 3. Update exception handling

```python
# Old
try:
    payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
    raise HTTPException(status_code=401, detail="Invalid token")

# New
try:
    payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except InvalidTokenError:
    raise HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Invalid or expired token",
        headers={"WWW-Authenticate": "Bearer"},
    )
```

### 4. Verify exp claim is in payload

PyJWT requires you to add the `"exp"` claim to your payload dict. If you don't, the token has no expiry.

```python
# Correct — exp is always set
def create_access_token(subject: str, expires_delta: timedelta | None = None):
    expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=15))
    to_encode = {"exp": expire, "sub": str(subject)}
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
```

## Auth DI Pattern (2026 Standard)

Use `OAuth2PasswordBearer` for extracting Bearer tokens from the Authorization header:

```python
from fastapi import Depends
from fastapi.security import OAuth2PasswordBearer
import jwt
from jwt.exceptions import InvalidTokenError

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")

@router.get("/me")
async def get_current_user(
    token: str = Depends(oauth2_scheme),
    use_cases: UserUseCases = Depends(get_user_use_cases),
):
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
        user_id = payload.get("sub")
        if user_id is None:
            raise HTTPException(status_code=401, detail="Invalid token")
        user = await use_cases.get_by_id(int(user_id))
        if user is None:
            raise HTTPException(status_code=404, detail="User not found")
        return {"id": user.id, "email": user.email}
    except InvalidTokenError:
        raise HTTPException(status_code=401, detail="Invalid token")
```

## Verifying the Migration

```python
# Quick sanity check
import jwt
from jwt.exceptions import InvalidTokenError

token = jwt.encode({"sub": "1", "exp": 9999999999}, "test-secret", algorithm="HS256")
payload = jwt.decode(token, "test-secret", algorithms=["HS256"])
assert payload["sub"] == "1"

# Invalid token should raise InvalidTokenError
try:
    jwt.decode("bad-token", "test-secret", algorithms=["HS256"])
except InvalidTokenError:
    pass
```

## Which Package to Install

```
uv add pyjwt[crypto]         # Production: JWT + RSA/ECDSA support
uv add pyjwt                  # Minimal: JWT only (HMAC)
uv remove python-jose        # Remove old
uv remove passlib             # Also remove passlib — broken on Python 3.13+
```
