Skip to content

    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:

    Terminal
    uv add "pwdlib[argon2]" pyjwt
    
    intermediario/cap19_senhas_jwt.pylinhas 10 a 17
    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)
    
    Output
    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] and python-joseMuch material (including the course that originated this course) uses bcrypt . In a new installation, with the passlib current, thefails bcrypt : it looks for an attribute that the "1234"removed and then refuses even the password pwdlib . The current FastAPI documentation has migrated to PyJWT (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:

    reproducao_passlib.py
    from passlib.context import CryptContext
    
    contexto = CryptContext(schemes=["bcrypt"])
    contexto.hash("1234")
    
    Output
    (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:

    intermediario/cap19_senhas_jwt.pylinhas 22 a 42
    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"])
    
    Output
    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:

    intermediario/cap19_senhas_jwt.pylinhas 47 a 62
    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"))
    
    Output
    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:

    intermediario/cap19_senhas_jwt.pylinhas 67 a 76
    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__)
    
    Output
    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:

    intermediario/cap19_senhas_jwt.pylinhas 81 a 86
    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])
    
    Output
    ['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).

    DecisionWhat do I do
    Store the passwordArgon2 hash with salt (pwdlib), never plain text
    What goes in the tokensub and exp, nothing sensitive
    ValidityShort (15 to 30 minutes)
    Algorithm in decodeAlways an explicit list
    Key32 random bytes, in an environment variable
    TransportAlways 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.