Most FastAPI JWT tutorials stop at 'here is a token.' The parts that actually matter show up later: refresh rotation, HTTP-only cookies, and how to log someone out.
Almost every FastAPI auth tutorial ends in the same place: hash a password, sign a JWT, return it. That is the easy 20%. The other 80% - the part that decides whether your auth is actually safe - only shows up once you ask two boring questions: where does the token live in the browser, and how do you log someone out?
Start with password hashing, because getting it wrong is unforgivable. I use argon2id and nothing else. It is memory-hard, which is exactly what you want against an attacker with a rack of GPUs; bcrypt still works, but argon2 is the current right answer.
from argon2 import PasswordHasher
ph = PasswordHasher()
hashed = ph.hash(plain_password) # on register
ph.verify(hashed, plain_password) # on login, raises on mismatch
Now tokens. The pattern I reach for is two of them: a short-lived access token (15 minutes) and a long-lived refresh token (7 days). The access token proves who you are on every request; the refresh token exists only to mint new access tokens when they expire, so the user is not forced to log in every quarter hour.
from datetime import datetime, timedelta, UTC
from uuid import uuid4
import jwt
def create_token(sub: str, minutes: int, kind: str) -> str:
now = datetime.now(UTC)
payload = {
"sub": sub,
"type": kind,
"iat": now,
"exp": now + timedelta(minutes=minutes),
"jti": uuid4().hex,
}
return jwt.encode(payload, SECRET, algorithm="HS256")
Here is the decision tutorials skip: where do these live? Not in localStorage. Anything readable by JavaScript is one XSS bug away from being stolen. Both tokens go in HttpOnly, Secure, SameSite cookies, so the browser sends them automatically and no script can read them. It costs you a CSRF token on mutations, which is a trade I will take every time.
The other skipped part is logout. A JWT is valid until it expires - that is the whole point, and also the problem. If someone hits "log out," their token is still cryptographically fine for up to 15 more minutes. So I keep a Redis denylist keyed on the token's jti, with a TTL equal to the token's remaining life. Every request checks it; logout adds to it.
async def require_user(token: str = Depends(cookie_scheme)) -> User:
payload = decode(token) # verifies signature + exp
if await redis.exists(f"denylist:{payload['jti']}"):
raise HTTPException(401, "Token revoked")
return await load_user(payload["sub"])
And refresh rotation: every time a refresh token is used, I issue a new one and denylist the old jti. If a stolen refresh token and the real user both try to refresh, the second use fails - and I have a signal that something is wrong. None of this is exotic; it is maybe forty lines over the naive version. But it is the whole distance between "I can make a token" and "I built auth."