DEV Community

RainyTech
RainyTech

Posted on AI-assisted

FastAPI JWT Authentication in 2026: A Complete Login API in One File (Argon2 + PyJWT, Tested)

Most FastAPI auth tutorials stop at "it returns a token". This one goes further: one file, 118 lines, five endpoints, and every response below comes from actually running it, including the failure cases most guides skip: no token, wrong password, a tampered token and an expired one.

I build it line by line in this video:

Tested with: Python 3.12 · FastAPI 0.128 · PyJWT 2.15 · pwdlib 0.2 (Argon2) · Pydantic 2.13 · Uvicorn 0.39.

What you'll build

Endpoint What it does
GET / Root message and a link to the docs
GET /health Health check
POST /token Log in with username and password and get a JWT
GET /me Returns the logged-in user (token required)
GET /protected Example of a guarded route (token required)

The flow: the client sends a username and password to /token, gets back a signed JWT, then sends it as Authorization: Bearer <token> on every protected request. One dependency, get_current_user(), does all the checking, so protecting a new route is a single line.

1. Install

requirements.txt:

fastapi>=0.115.0
uvicorn[standard]>=0.30.0
pyjwt>=2.9.0
pwdlib[argon2]>=0.2.0
python-multipart>=0.0.9
python-dotenv>=1.0.1
Enter fullscreen mode Exit fullscreen mode
python3.12 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
Enter fullscreen mode Exit fullscreen mode
  • PyJWT signs and verifies tokens. (FastAPI's docs moved from python-jose to PyJWT.)
  • pwdlib[argon2] hashes passwords with Argon2, the modern replacement for passlib.
  • python-multipart is needed because the login endpoint reads an HTML-style form.

Use Python 3.10 or newer: the code uses str | None type hints, which Pydantic rejects on 3.9.

2. Config: secret, algorithm, expiry

load_dotenv()

app = FastAPI(title="FastAPI JWT Authentication", version="1.0.0")

JWT_SECRET = os.getenv("JWT_SECRET", "dev-only-secret-change-me")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

password_hash = PasswordHash.recommended()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
Enter fullscreen mode Exit fullscreen mode

PasswordHash.recommended() picks Argon2. OAuth2PasswordBearer(tokenUrl="token") tells FastAPI where tokens come from. It also powers the Authorize button in Swagger UI.

Generate a real secret and put it in .env. More on why below:

python -c "import secrets; print(secrets.token_urlsafe(32))"
Enter fullscreen mode Exit fullscreen mode
JWT_SECRET=paste-the-output-here
Enter fullscreen mode Exit fullscreen mode

3. Users, models and password checks

For the demo, users live in a dict, and the password is hashed when the app starts, so no plain-text password is ever stored:

DEMO_USERS = {
    "demo": {
        "username": "demo",
        "full_name": "Demo User",
        "hashed_password": password_hash.hash("demo123"),
    }
}


class Token(BaseModel):
    access_token: str
    token_type: str


class User(BaseModel):
    username: str
    full_name: str | None = None


def authenticate_user(username: str, password: str) -> User | None:
    record = DEMO_USERS.get(username)
    if record is None:
        return None
    if not password_hash.verify(password, record["hashed_password"]):
        return None
    return get_user(username)
Enter fullscreen mode Exit fullscreen mode

Notice that User has no password field. Because the routes declare response_model=User, the hash can never leak into a response by accident.

4. Creating the token

def create_access_token(data: dict, expires: timedelta | None = None) -> str:
    payload = data.copy()
    if expires is None:
        expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    payload["exp"] = datetime.now(timezone.utc) + expires
    return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM)
Enter fullscreen mode Exit fullscreen mode

Use datetime.now(timezone.utc), not the deprecated utcnow(). PyJWT checks exp for you when it decodes.

5. The dependency that guards every route

async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> User:
    error = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM])
    except InvalidTokenError:
        raise error
    username = payload.get("sub")
    if username is None:
        raise error
    user = get_user(username)
    if user is None:
        raise error
    return user
Enter fullscreen mode Exit fullscreen mode

Two details matter here:

  • algorithms=[ALGORITHM] is a list you pass explicitly. Never let the token's own header decide the algorithm.
  • Every failure returns the same 401. A bad signature, an expired token and an unknown user all look identical to the client, which gives attackers nothing to learn from.

6. The endpoints

@app.post("/token", response_model=Token)
async def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]):
    user = authenticate_user(form.username, form.password)
    if user is None:
        raise HTTPException(status_code=401, detail="Incorrect username or password")
    token = create_access_token({"sub": user.username})
    return Token(access_token=token, token_type="bearer")


@app.get("/me", response_model=User)
async def read_me(user: Annotated[User, Depends(get_current_user)]):
    return user


@app.get("/protected")
async def protected(user: Annotated[User, Depends(get_current_user)]):
    return {
        "message": f"Hello {user.username}, your JWT is valid.",
        "framework": "FastAPI",
        "language": "Python",
    }
Enter fullscreen mode Exit fullscreen mode

To protect any new route, add user: Annotated[User, Depends(get_current_user)] to its parameters. That's the whole pattern.

7. Run it

uvicorn app:app --reload
Enter fullscreen mode Exit fullscreen mode

Open http://127.0.0.1:8000/docs, click Authorize, and log in with demo / demo123. Or test it with curl, as I did below.

8. Real test results: the happy path

Log in:

curl -X POST http://127.0.0.1:8000/token -d "username=demo&password=demo123"
Enter fullscreen mode Exit fullscreen mode
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdW...","token_type":"bearer"}
Enter fullscreen mode Exit fullscreen mode

A JWT is three base64url parts separated by dots: header, payload, signature. Decoding the first two:

{"alg":"HS256","typ":"JWT"}
{"sub":"demo","exp":1791785986}
Enter fullscreen mode Exit fullscreen mode

The payload is readable by anyone. It's signed, not encrypted, so never put passwords or private data in it.

Use the token:

curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/me
Enter fullscreen mode Exit fullscreen mode
{"username":"demo","full_name":"Demo User"}
Enter fullscreen mode Exit fullscreen mode
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/protected
Enter fullscreen mode Exit fullscreen mode
{"message":"Hello demo, your JWT is valid.","framework":"FastAPI","language":"Python"}
Enter fullscreen mode Exit fullscreen mode

9. Real test results: the failure cases

This is the part that tells you your auth actually works.

Test Status Response
/me with no token 401, www-authenticate: Bearer {"detail":"Not authenticated"}
Login with a wrong password 401 {"detail":"Incorrect username or password"}
/me with the last character of the token changed 401 {"detail":"Could not validate credentials"}
/me with a token that expired 1 minute ago 401 {"detail":"Could not validate credentials"}

Changing a single character breaks the signature, and the expired token is rejected by PyJWT's built-in exp check. Neither needed any extra code.

The warning PyJWT 2.15 gave me

When I signed a token with the fallback secret, PyJWT printed:

InsecureKeyLengthWarning: The HMAC key is 25 bytes long, which is below the
minimum recommended length of 32 bytes for SHA256. See RFC 7518 Section 3.2.
Enter fullscreen mode Exit fullscreen mode

"dev-only-secret-change-me" is only 25 bytes. For HS256 your secret should be at least 32 bytes. secrets.token_urlsafe(32) gives you 43 characters, which is plenty. Set JWT_SECRET in .env and the warning disappears.

Before you ship this

This is a learning foundation, not a finished security system:

  • Move users into a real database, and hash passwords at signup, not at startup.
  • Keep the secret out of the repo. Use a secrets manager in production, and rotate it.
  • Add refresh tokens, so the 30-minute access token can stay short-lived.
  • Rate-limit /token to slow down password guessing.
  • Use HTTPS. A bearer token is as good as a password while it's valid.
  • Keep the failure tests from section 9 in your test suite.

Full video

Every line is typed on screen, then tested live in Swagger UI: FastAPI JWT Authentication Tutorial (2026).

Stuck on an error? Paste the traceback in the comments and I'll take a look.


Drafted with AI assistance. The code is from my own project, and every response above comes from a real run I did on October 12, 2026.

Top comments (0)