Lesson 3 - JWT Access Tokens
Welcome to JWT Access Tokens
In Lesson 2 you turned a token into the current user — but the token itself was still flimsy. It was just the username with -token glued on, so anyone could forge one for any account by typing a name, and it never expired. A real access token has to be tamper-proof (the server can tell if someone edited it) and time-limited (it stops working after a while). The standard tool for both jobs is the JSON Web Token, or JWT.
In this lesson you’ll replace the placeholder with a genuine signed JWT. You’ll encode the username and an expiry into it, hand it out at login, and verify it on every protected request — letting the library reject forged or stale tokens for you.
By the end of this lesson, you will be able to:
- Explain what a JWT is — its three parts and the claims it carries
- Issue a signed JWT with
jwt.encode, including asuband anexp - Verify and decode a JWT with
jwt.decode, returning401onInvalidTokenError - Rely on automatic expiry so old tokens stop working without extra code
You’ll build directly on the Task Manager auth from the last two lessons. Let’s begin.
What a JWT Actually Is
A JWT is a single string with three parts, separated by dots: header.payload.signature. Each part is Base64-encoded, which is why a token looks like a long blob of gibberish such as eyJhbGciOiJIUzI1NiIs....
- The header says how the token was signed (the algorithm — we’ll use
HS256). - The payload holds the claims — the actual data, as a small JSON object. Common claims are
sub(the subject, here the username) andexp(the expiry time). - The signature is a cryptographic stamp computed from the header, payload, and a secret key. If anyone changes a single character of the header or payload, the signature no longer matches, and verification fails.
Here’s the crucial part to internalize: a JWT is signed, not encrypted. The payload is only Base64-encoded, which is trivially reversible — anyone holding the token can read its claims. What they can’t do is change them, because they don’t have your secret key to produce a valid new signature. So the signature buys you integrity (no tampering), not secrecy.
To make this concrete, here’s a token being created and then decoded back into its claims:
from datetime import datetime, timedelta, timezone
import jwt
SECRET = "CHANGE-ME-IN-PRODUCTION"
ALGORITHM = "HS256"
payload = {
"sub": "ada",
"exp": datetime.now(timezone.utc) + timedelta(minutes=30),
}
token = jwt.encode(payload, SECRET, algorithm=ALGORITHM)
print("token (first 20):", token[:20] + "...")
print("decoded:", jwt.decode(token, SECRET, algorithms=[ALGORITHM]))token (first 20): eyJhbGciOiJIUzI1NiIs...
decoded: {'sub': 'ada', 'exp': 1782605505}Notice that exp came back as a plain integer — a Unix timestamp (seconds since 1970). PyJWT converts your datetime to that format when encoding, and it’s the value it checks against the current time when decoding. Because the payload is readable, never put anything sensitive in it. The sub is fine; a password or API key never belongs there.
Signed, not encrypted, and the secret is sacred
A JWT’s payload is readable by anyone holding the token, so treat it as public: put identifiers like the username in it, never secrets. The signature is what makes it trustworthy — and it’s only as trustworthy as your SECRET. Anyone with that key can mint valid tokens for any user. The literal "CHANGE-ME-IN-PRODUCTION" here is a placeholder so the lesson runs; in a real app, load the secret from an environment variable or a secrets manager, make it long and random, and never commit it to version control.
Issuing a Token with jwt.encode
Now let’s wrap token creation in a small helper and plug it into the login endpoint. The helper builds the claims and signs them; the only inputs are the username and how long the token should live:
from datetime import datetime, timedelta, timezone
import jwt
SECRET = "CHANGE-ME-IN-PRODUCTION" # load from an env var in production
ALGORITHM = "HS256"
def make_token(username: str, minutes: int = 30):
payload = {
"sub": username,
"exp": datetime.now(timezone.utc) + timedelta(minutes=minutes),
}
return jwt.encode(payload, SECRET, algorithm=ALGORITHM)The exp claim is computed now plus the lifetime, so every token carries its own deadline baked in. With the helper ready, the /token endpoint from Lesson 1 changes in just one spot — instead of username + "-token", it returns a real JWT:
from fastapi import Depends
from fastapi.security import OAuth2PasswordRequestForm
@app.post("/token")
def login(form: OAuth2PasswordRequestForm = Depends()):
# (verify the password with bcrypt here, as in Lesson 2)
return {"access_token": make_token(form.username), "token_type": "bearer"}The response shape is unchanged — access_token plus token_type: "bearer" — so existing clients and the docs “Authorize” button keep working. The only difference is that access_token is now a signed, self-expiring JWT instead of a guessable string.
Verifying the Token and Loading the User
The other half is verification. In Lesson 2, get_current_user just looked the raw token up in a dictionary. Now it has to decode and validate the JWT first, then pull the username out of the sub claim and load the matching user:
from fastapi import Depends, HTTPException
import jwt
def get_current_user(token: str = Depends(oauth2_scheme)):
creds_exc = HTTPException(status_code=401, detail="Could not validate credentials")
try:
payload = jwt.decode(token, SECRET, algorithms=[ALGORITHM])
username = payload.get("sub")
except jwt.InvalidTokenError:
raise creds_exc
user = users.get(username)
if user is None:
raise creds_exc
return userThe work happens inside jwt.decode. It recomputes the signature using your SECRET and compares it to the one in the token. If they don’t match — because the token was edited, signed with the wrong key, or isn’t a real JWT at all — it raises an error. The key insight is that jwt.InvalidTokenError is the base class for all of these failures, so one except catches them all and turns them into a clean 401. (For example, a forged token raises jwt.InvalidSignatureError, and an expired one raises jwt.ExpiredSignatureError — both are subclasses of InvalidTokenError.)
If decoding succeeds, the claims are trustworthy, so we read sub and look the user up exactly as before. Let’s run the full flow against the protected /me route:
from fastapi.testclient import TestClient
client = TestClient(app)
# 1. Log in -> receive a real JWT
login = client.post("/token", data={"username": "ada", "password": "x"})
token = login.json()["access_token"]
print("login:", login.status_code, "| token:", token[:20] + "...")
# 2. Call /me with the JWT
me = client.get("/me", headers={"Authorization": f"Bearer {token}"})
print("/me with JWT:", me.status_code, me.json())
# 3. Call /me with a garbage token
bad = client.get("/me", headers={"Authorization": "Bearer not.a.token"})
print("/me with junk:", bad.status_code, bad.json())login: 200 | token: eyJhbGciOiJIUzI1NiIs...
/me with JWT: 200 {'username': 'ada', 'full_name': 'Ada Lovelace'}
/me with junk: 401 {'detail': 'Could not validate credentials'}A valid token sails through and returns the user; not.a.token isn’t a real JWT, so jwt.decode raises InvalidTokenError and our handler converts it into a 401. No more forging a token by simply typing a username.
Expiry: Old Tokens Reject Themselves
The best part of the exp claim is that you don’t have to write any expiry logic. When you call jwt.decode, PyJWT compares the exp timestamp against the current time, and if the token is past its deadline it raises jwt.ExpiredSignatureError — which, being a subclass of InvalidTokenError, is already caught by the same except block.
To prove it without waiting 30 minutes, we can mint a token that’s already expired by passing a negative lifetime, then try to use it:
# A token created with minutes=-1 is expired the moment it's made.
expired = make_token("ada", minutes=-1)
resp = client.get("/me", headers={"Authorization": f"Bearer {expired}"})
print("/me with expired token:", resp.status_code, resp.json())/me with expired token: 401 {'detail': 'Could not validate credentials'}The signature on that token is perfectly valid — but it’s stale, so jwt.decode rejects it. This is exactly why access tokens are usually short-lived: if one is ever leaked, the window an attacker can use it in is small, and the client simply logs in again to get a fresh one. You got all of that for free from a single exp claim.
Practice Exercises
Exercise 1: Read the claims
Given a JWT you minted with make_token("ada"), how could you print its claims, and what two keys would you see? Could someone else read those claims without your secret?
Hint
Call jwt.decode(token, SECRET, algorithms=[ALGORITHM]) to get the payload — you’d see sub (the username) and exp (a Unix timestamp). Because the payload is only Base64-encoded, anyone holding the token can read those claims even without the secret; the secret is only needed to verify or create a valid signature, not to read the data.
Exercise 2: One except for many failures
get_current_user catches only jwt.InvalidTokenError, yet it handles forged tokens, malformed strings, and expired tokens alike. Why does a single except cover all three?
Hint
jwt.InvalidTokenError is the base class for PyJWT’s decode errors. A bad signature raises InvalidSignatureError and an expired token raises ExpiredSignatureError, and both inherit from InvalidTokenError — so catching the base class catches every subclass at once, and you turn them all into the same 401.
Exercise 3: The danger of the secret
A teammate suggests hard-coding SECRET = "CHANGE-ME-IN-PRODUCTION" and committing it to the repo “just so everyone has it.” Why is that dangerous, and what should you do instead?
Hint
Anyone who knows the secret can forge a valid token for any user, so committing it lets anyone with repo access (or anyone who finds it in history) impersonate your users. Keep it out of source control entirely: load it from an environment variable or a secrets manager, and use a long, random value in production.
Summary
You upgraded the Task Manager’s placeholder token into a real JSON Web Token. A JWT is a header.payload.signature string whose payload holds claims like sub (the username) and exp (the expiry). It is signed, not encrypted, so the claims are readable but tamper-proof — the signature, computed with your SECRET, is what makes it trustworthy. You issued tokens with jwt.encode, baking in a sub and an exp, and verified them with jwt.decode, catching jwt.InvalidTokenError to return a clean 401 for forged, malformed, or expired tokens. Best of all, the exp claim makes tokens reject themselves once they’re stale, with no extra code.
Key Concepts
- JWT — a signed
header.payload.signaturetoken carrying JSON claims. - Claims — the payload data, e.g.
sub(subject/username) andexp(expiry). jwt.encode— signs a claims dict with aSECRETto produce the token.jwt.decode— verifies the signature and expiry, returning the claims or raising.jwt.InvalidTokenError— base class for decode failures (bad signature, expired, malformed); catch it and return401.- Signed, not encrypted — claims are readable; never put secrets in the payload, and protect the
SECRET.
Why This Matters
Self-contained, signed tokens are how modern APIs scale authentication: the server can trust a request just by verifying the signature, without a database lookup for the token itself, and expiry limits the damage of a leak. JWTs are the de facto standard across the industry, so knowing how to issue and validate them is a skill you’ll reach for in nearly every API you build. With trustworthy, expiring tokens in hand, the next step is deciding what each authenticated user is allowed to do.
Next Steps
Continue to Lesson 4 - Protecting Routes and Scopes
Use the authenticated user to guard routes and grant fine-grained permissions with scopes.
Back to Module Overview
Return to the Authentication and Security module overview
Continue Building Your Skills
Your Task Manager now hands out real, signed, self-expiring access tokens and verifies them on every protected request — forged and stale tokens bounce off with a 401 automatically. With identity established and trustworthy, the next lesson moves from “who are you” to “what may you do,” using the authenticated user to protect routes and assign scoped permissions.