Chapter 19, Intermediate
Passwords and JWT tokens
Authenticating is proving who you are. This involves two things that are confused and different: storing the password in a way that a leak does not reveal it, and proving identity in subsequent requests without sending the password again.
Never store the password, store a hash
If the database leaks with passwords in plain text, all users are compromised at once. The solution is to store a hash: the result of a one-way function. You can calculate the hash of a password, but does not you cannot go back from the hash to the password. To verify a login, you calculate the hash of the entered password and compare.
Not every hash is suitable. Fast functions like SHA-256 were made to be fast, and this is a flaw here: an attacker tests billions of passwords per second. For passwords, deliberately slow functions are used and with salt (a random value different for each password). The current recommendation from the FastAPI documentation is Argon2, from the library pwdlib:
uv add "pwdlib[argon2]" pyjwt
from pwdlib import PasswordHash
hasher = PasswordHash.recommended()
senha_hash = hasher.hash("1234")
print(senha_hash.startswith("$argon2id$"))
print(hasher.verify("1234", senha_hash), hasher.verify("errada", senha_hash))
print(hasher.hash("1234") != senha_hash)
True
True False
True
The last result shows the salt in action: the same password generates different hashes
each time, because each hash carries its salt. Two users with the same password do not have the same stored hash, and a pre-computed hash table does not work.
The course uses `passlib` and `python-jose`, and this broke
passlib[bcrypt]andpython-joseMuch material (including the course that originated this course) usesbcrypt. In a new installation, with thepasslibcurrent, thefailsbcrypt: it looks for an attribute that the"1234"removed and then refuses even the passwordpwdlib. The current FastAPI documentation has migrated toPyJWT(hash) and
(token), and that is what this course uses. passlib 1.7.4 I reproduced the problem in an isolated environment, with bcrypt 5.0.0(the latest published version) and . This is the code and the actual output:
from passlib.context import CryptContext
contexto = CryptContext(schemes=["bcrypt"])
contexto.hash("1234")
(trapped) error reading bcrypt version
AttributeError: module 'bcrypt' has no attribute '__about__'
ValueError: password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])
If you inherit a project with passlib, the pwdlib it also reads old bcrypt hashes, which allows for gradual migration.
What is a JWT
After login, the server delivers a token, and the client sends it with each subsequent request, instead of resending the password. The most used format is JWT (JSON Web Token): three parts in Base64 separated by dots, the header (the algorithm), the body (the information, called claims) and the signature:
import base64
import json
from datetime import datetime, timedelta, timezone
import jwt
CHAVE = "uma-chave-secreta-com-no-minimo-32-bytes-0123"
ALGORITMO = "HS256"
payload = {"sub": "ana", "exp": datetime.now(timezone.utc) + timedelta(minutes=30)}
token = jwt.encode(payload, CHAVE, algorithm=ALGORITMO)
print(type(token).__name__, token.count("."))
def decodificar_parte(parte: str) -> dict:
return json.loads(base64.urlsafe_b64decode(parte + "=" * (-len(parte) % 4)))
cabecalho, corpo, assinatura = token.split(".")
print(decodificar_parte(cabecalho))
print(decodificar_parte(corpo)["sub"])
str 2
{'alg': 'HS256', 'typ': 'JWT'}
ana
The most important point: the body of the JWT is not encrypted. I read the sub just by decoding Base64, without the key. The signature ensures that the content has not been altered, but anyone who sees the token can read. Therefore, never put a password, a document, or any sensitive data in it. Store only what is necessary: the user identifier (sub) and the expiration (exp).
Verify the token
The jwt.decode checks the signature and the expiration. Each failure has its exception, and all inherit from InvalidTokenError:
def conferir(texto: str, chave: str = CHAVE) -> str:
try:
return jwt.decode(texto, chave, algorithms=[ALGORITMO])["sub"]
except jwt.ExpiredSignatureError:
return "expirado"
except jwt.InvalidTokenError:
return "inválido"
expirado = jwt.encode(
{"sub": "ana", "exp": datetime.now(timezone.utc) - timedelta(seconds=1)}, CHAVE, algorithm=ALGORITMO
)
de_outro_servidor = jwt.encode({"sub": "admin"}, "outra-chave-também-bem-comprida-0123456", algorithm=ALGORITMO)
print(conferir(token), conferir(expirado), conferir(de_outro_servidor))
print(conferir(token, chave="chave-errada-bem-comprida-0123456789012"))
ana expirado inválido
inválido
`algorithms=[...]` is mandatory for security
The decode requires you to specify which algorithms you accept. There is a classic attack where the attacker forges a token with the algorithm none (without a signature) and waits for the server to accept it. By fixing the list, the server rejects:
sem_assinatura = jwt.encode({"sub": "admin"}, None, algorithm="none")
try:
jwt.decode(sem_assinatura, CHAVE, algorithms=[ALGORITMO])
except jwt.InvalidTokenError as erro:
print("token sem assinatura recusado:", type(erro).__name__)
try:
jwt.decode(token, CHAVE)
except jwt.PyJWTError as erro:
print("sem a lista de algoritmos:", type(erro).__name__)
token sem assinatura recusado: InvalidAlgorithmError
sem a lista de algoritmos: DecodeError
The secret key
The security of a JWT with HS256 is the security of the key. Whoever has it can forge tokens for any user. The course uses "my_secret", and the current PyJWT complains about this:
import warnings
with warnings.catch_warnings(record=True) as avisos:
warnings.simplefilter("always")
jwt.encode({"a": 1}, "my_secret", algorithm="HS256")
print([a.category.__name__ for a in avisos])
['InsecureKeyLengthWarning']
The key must be at least 32 bytes long and be random. Generate one with openssl rand -hex 32 (or secrets.token_urlsafe(32)) and store it in an environment variable, never in the code or in Git (chapter 23).
| Decision | What do I do |
|---|---|
| Store the password | Argon2 hash with salt (pwdlib), never plain text |
| What goes in the token | sub and exp, nothing sensitive |
| Validity | Short (15 to 30 minutes) |
Algorithm in decode | Always an explicit list |
| Key | 32 random bytes, in an environment variable |
| Transport | Always HTTPS, in the header Authorization: Bearer |
Exercise 1
An access token with validity
Write criar_token(usuario, minutos), which returns a JWT with sub and exp, and ler_token(token), which returns the sub or None if the token is invalid or expired. Check a valid token, one with negative validity, and one that is tampered.