Python course Β· Module 6: Async and FastAPI
JWT Authentication - access tokens
In this lesson6
Welcome! Darwin here with JWT authentication for FastAPI!
Our Safari API already accepts and stores observations, but right now anyone can add to the database that they spotted a penguin on the savanna. We need a gate where a tourist shows a pass before changing anything.
JWT (JSON Web Tokens) is a standard for access tokens - instead of session cookies we use stateless tokens!
"Stateless" means the server does not keep a list of logged-in people. Everything it needs travels inside the token itself, and the server only checks its signature. The price is that an issued token cannot easily be revoked before it expires, which is why access tokens are short-lived.
Safari analogy: A JWT is like an electronic safari pass - it contains all the info about the tourist (ID, permissions), it is digitally signed (it cannot be forged), and it has an expiry date (expires)!
What a token is made of
A JWT is three pieces of text joined by dots: header.payload.signature. The header says which algorithm signed the token. The payload holds the data, the so-called claims, for example sub (who the token is about) and exp (until when it is valid). The signature is computed from the first two parts and a secret key. The header and payload are only Base64URL-encoded, not encrypted - anyone can read them, so never put a password in a token. Only a pass without a seal can be forged, and nobody can recreate the seal without the key.
Installation
We install a token library, a password hashing library and python-multipart, which FastAPI needs to read forms:
1pip install python-jose[cryptography] passlib[bcrypt] python-multipartYou will find this set in many projects. The current FastAPI documentation, however, recommends PyJWT and pwdlib with the Argon2 algorithm (pip install pyjwt "pwdlib[argon2]"), because passlib has not seen a new release since 2020. I will show the difference at the end of the lesson.
Password hashing
Passwords never go into the database as they are. We store a hash - a one-way digest from which the original cannot be recovered. CryptContext from passlib hides the details of the bcrypt algorithm:
1from passlib.context import CryptContext
2
3pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
4
5def hash_password(password: str) -> str:
6 return pwd_context.hash(password)
7
8def verify_password(plain_password: str, hashed_password: str) -> bool:
9 return pwd_context.verify(plain_password, hashed_password)You call hash_password once, at registration. At login, verify_password hashes the given password and compares the result with the stored digest - the password itself is kept nowhere.
Creating JWT tokens
First the constants: the secret key, the algorithm and the token lifetime. HS256 is an HMAC signature with SHA-256, where the same key both signs and verifies:
1from jose import JWTError, jwt
2from datetime import datetime, timedelta, timezone
3
4SECRET_KEY = "your-secret-key-keep-it-secret"
5ALGORITHM = "HS256"
6ACCESS_TOKEN_EXPIRE_MINUTES = 30The key in the example is only a placeholder. Generate a real one with openssl rand -hex 32 and keep it in an environment variable, never in the repository.
Now the function that issues the pass. It copies the data, adds the exp claim and signs everything:
1def create_access_token(data: dict):
2 to_encode = data.copy()
3 expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
4 to_encode.update({"exp": expire})
5 encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
6 return encoded_jwtdata.copy() protects the caller's dict from being modified. We compute the time with datetime.now(timezone.utc). In older code, including some exercises, you will see datetime.utcnow() - since Python 3.12 this method is marked as deprecated, because it returns a time without a timezone.
Decoding works the other way round:
1def decode_token(token: str):
2 try:
3 payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
4 return payload
5 except JWTError:
6 return Nonejwt.decode checks the signature and automatically rejects a token whose exp has passed. The algorithms list matters: it says which algorithms you accept, so nobody can smuggle in a token signed with a different one. Any problem ends here with the value None.
Login endpoint
Time for the gate. OAuth2PasswordBearer extracts the token from the Authorization: Bearer <token> header, and tokenUrl points to the login address used by the "Authorize" button in the Swagger docs:
1from fastapi import FastAPI, Depends, HTTPException, status
2from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
3
4app = FastAPI()
5
6oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")This object verifies nothing by itself. If the header is missing it answers with 401 straight away, and if it is present it passes on the raw token text.
The login endpoint accepts a form with username and password fields, which is why we needed python-multipart:
1@app.post("/token")
2async def login(form_data: OAuth2PasswordRequestForm = Depends()):
3 # Check user in the database
4 user = authenticate_user(form_data.username, form_data.password)
5
6 if not user:
7 raise HTTPException(
8 status_code=status.HTTP_401_UNAUTHORIZED,
9 detail="Incorrect username or password"
10 )
11
12 access_token = create_access_token(data={"sub": user.username})
13 return {"access_token": access_token, "token_type": "bearer"}The authenticate_user function is not part of FastAPI - it is your own code that looks up the user in the database and calls verify_password. The response with access_token and token_type: "bearer" is the format required by the OAuth2 specification.
Finally, the dependency that turns a token into a user, and the protected route:
1async def get_current_user(token: str = Depends(oauth2_scheme)):
2 payload = decode_token(token)
3 if not payload:
4 raise HTTPException(status_code=401, detail="Invalid token")
5
6 username = payload.get("sub")
7 return {"username": username}
8
9@app.get("/protected")
10async def protected_route(current_user: dict = Depends(get_current_user)):
11 return {"message": f"Hello {current_user['username']}!"}protected_route knows nothing about tokens: it receives a ready user through Depends, exactly like the database session in the previous lesson. The FastAPI documentation adds the headers={"WWW-Authenticate": "Bearer"} header to such a 401 exception, as the standard says - I recommend doing the same.
Test:
Let's check everything from the terminal: first the login, then a request with the token.
1# Login
2curl -X POST http://localhost:8000/token \
3 -d "username=darwin&password=secret123"
4
5# Response: {"access_token":"eyJ...", "token_type":"bearer"}
6
7# Protected route
8curl http://localhost:8000/protected \
9 -H "Authorization: Bearer eyJ..."The token in the response starts with eyJ, because that is what the encoded beginning of the JSON {" looks like. Without the Authorization header the /protected route returns 401.
The PyJWT version
If you follow the current FastAPI documentation, only the imports and the exception name change:
1import jwt
2from jwt.exceptions import InvalidTokenError
3
4# You call jwt.encode(...) and jwt.decode(...) exactly as above,
5# and in decode_token you catch InvalidTokenError instead of JWTErrorThe rest of the code, including OAuth2PasswordBearer and the endpoints, stays unchanged. In new projects, choose PyJWT.
Next lesson: Testing with pytest! We will check automatically that the gate lets in the right tourists.
Remember: a JWT is a pass with a seal and an expiry date - anyone can read it, but nobody can forge it without the key.
Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What is JWT (JSON Web Token)?
2. What three parts does a JWT consist of?
These are 2 of 3 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Horizontal ordering
What does the Authorization header with a Bearer token look like?
- Vertical ordering
Arrange the function for creating a JWT token:
- Click in order
Arrange the JWT token decoding:
- Code editor
Write a GET /me endpoint that requires a JWT token and returns the current user's data.