Authentication Checkpoint
25 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)Fix the mistakes
The slips that cost marks in this module. Spot each one, then check the fix.
A working login system needs hashing, a token and a refresh step to fit together. This checkpoint builds a small service from the module's pieces and tests the choices behind it.
Put the pieces together
Sign-up hashes the password and stores only the hash. Login checks the hash and returns two tokens. Every protected route reads the access token. Keep each step in one small function, so each can be tested alone.
USERS[body.username] = hash_password(body.password)
def login(form, stored): if stored is None or not verify_password(form.password, stored): raise HTTPException(400, "Incorrect username or password") return {"access_token": make_token(form.username, "access", 900)}Example 1 · Sign up, log in and read your profile
import hashlib, hmac, secrets, timefrom typing import Annotatedimport jwtfrom fastapi import Depends, FastAPI, HTTPExceptionfrom fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestFormfrom fastapi.testclient import TestClientfrom pydantic import BaseModel
SECRET = "demo-secret-change-me"USERS = {}app = FastAPI()oauth2 = OAuth2PasswordBearer(tokenUrl="token")
def hash_password(password, salt=None): salt = salt or secrets.token_bytes(16) digest = hashlib.scrypt(password.encode(), salt=salt, n=2**10, r=8, p=1, dklen=32) return salt.hex() + "$" + digest.hex()
def verify_password(password, stored): salt_hex, digest_hex = stored.split("$") again = hash_password(password, bytes.fromhex(salt_hex)).split("$")[1] return hmac.compare_digest(again, digest_hex)
def make_token(sub, kind, seconds): return jwt.encode({"sub": sub, "type": kind, "exp": int(time.time()) + seconds}, SECRET, algorithm="HS256")
def read_token(token, kind): try: claims = jwt.decode(token, SECRET, algorithms=["HS256"]) except jwt.PyJWTError: raise HTTPException(401, "Invalid or expired token", headers={"WWW-Authenticate": "Bearer"}) if claims["type"] != kind: raise HTTPException(401, "Wrong token type", headers={"WWW-Authenticate": "Bearer"}) return claims["sub"]
class SignUp(BaseModel): username: str password: str
@app.post("/signup", status_code=201)def signup(body: SignUp): if body.username in USERS: raise HTTPException(409, "Username taken") USERS[body.username] = hash_password(body.password) return {"username": body.username}
@app.post("/token")def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]): stored = USERS.get(form.username) if stored is None or not verify_password(form.password, stored): raise HTTPException(400, "Incorrect username or password") return {"access_token": make_token(form.username, "access", 900), "refresh_token": make_token(form.username, "refresh", 7 * 86400), "token_type": "bearer"}
@app.get("/me")def me(token: Annotated[str, Depends(oauth2)]): return {"user": read_token(token, "access")}
client = TestClient(app)
print(client.post("/signup", json={"username": "asha", "password": "Orange#2024"}).status_code)print(client.post("/signup", json={"username": "asha", "password": "other"}).status_code)print(USERS["asha"].count("$"), "Orange" in USERS["asha"])
tokens = client.post("/token", data={"username": "asha", "password": "Orange#2024"}).json()print(client.get("/me", headers={"Authorization": "Bearer " + tokens["access_token"]}).json())print(client.post("/token", data={"username": "asha", "password": "nope"}).status_code)201
409
1 False
{'user': 'asha'}
400What this code does
- The stored text has one
$between the salt and the hash and never contains the password. - A duplicate username gives 409 and a wrong password gives 400.
The plain password is used only inside these two functions. It is never logged, saved or returned.
Check a token in the right order
A token is trusted only after three checks: the signature is valid, it has not expired, and its type fits the route. Do the checks inside one function, and use it everywhere.
try: claims = jwt.decode(token, SECRET, algorithms=["HS256"])except jwt.PyJWTError: raise HTTPException(401, "Invalid or expired token")if claims["type"] != kind: raise HTTPException(401, "Wrong token type")Example 2 · One function, five tokens
import timeimport jwt
SECRET = "demo-secret-change-me"
def make(sub, kind, seconds): return jwt.encode({"sub": sub, "type": kind, "exp": int(time.time()) + seconds}, SECRET, algorithm="HS256")
def check(token, kind): try: claims = jwt.decode(token, SECRET, algorithms=["HS256"]) except jwt.ExpiredSignatureError: return "expired" except jwt.PyJWTError: return "invalid" return "ok" if claims["type"] == kind else "wrong type"
fresh = make("asha", "access", 900)stale = make("asha", "access", -10)refresh = make("asha", "refresh", 3600)other = jwt.encode({"sub": "asha", "type": "access"}, "attacker-key", algorithm="HS256")
for label, token in [("fresh", fresh), ("stale", stale), ("refresh", refresh), ("other key", other), ("text", "abc")]: print(label, check(token, "access"))fresh ok stale expired refresh wrong type other key invalid text invalid
What this code does
- The same
checkfunction separates expired, forged, wrong-type and broken tokens. - A negative lifetime is a quick way to make an expired token for a test.
Reading the claims with verify_signature off is only for debugging. Never make a decision from unverified claims.
Find the weak spot
Reviewing a design is a skill interviewers test. Look for fast hashes such as plain SHA-256, a missing salt, tokens that never expire, == on secrets, a secret key written in code, and tokens kept in localStorage.
SECRET = "abc123" # key written in the codetoken = jwt.encode({"sub": user}, SECRET) # no exp, no typeif stored_hash == sha256(password): # fast hash, plain == ...Example 3 · Renew an expired access token
import hashlib, hmac, secrets, timefrom typing import Annotatedimport jwtfrom fastapi import Depends, FastAPI, HTTPExceptionfrom fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestFormfrom fastapi.testclient import TestClientfrom pydantic import BaseModel
SECRET = "demo-secret-change-me"USERS = {}app = FastAPI()oauth2 = OAuth2PasswordBearer(tokenUrl="token")
def hash_password(password, salt=None): salt = salt or secrets.token_bytes(16) digest = hashlib.scrypt(password.encode(), salt=salt, n=2**10, r=8, p=1, dklen=32) return salt.hex() + "$" + digest.hex()
def verify_password(password, stored): salt_hex, digest_hex = stored.split("$") again = hash_password(password, bytes.fromhex(salt_hex)).split("$")[1] return hmac.compare_digest(again, digest_hex)
def make_token(sub, kind, seconds): return jwt.encode({"sub": sub, "type": kind, "exp": int(time.time()) + seconds}, SECRET, algorithm="HS256")
def read_token(token, kind): try: claims = jwt.decode(token, SECRET, algorithms=["HS256"]) except jwt.PyJWTError: raise HTTPException(401, "Invalid or expired token", headers={"WWW-Authenticate": "Bearer"}) if claims["type"] != kind: raise HTTPException(401, "Wrong token type", headers={"WWW-Authenticate": "Bearer"}) return claims["sub"]
class SignUp(BaseModel): username: str password: str
@app.post("/signup", status_code=201)def signup(body: SignUp): if body.username in USERS: raise HTTPException(409, "Username taken") USERS[body.username] = hash_password(body.password) return {"username": body.username}
@app.post("/token")def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]): stored = USERS.get(form.username) if stored is None or not verify_password(form.password, stored): raise HTTPException(400, "Incorrect username or password") return {"access_token": make_token(form.username, "access", 900), "refresh_token": make_token(form.username, "refresh", 7 * 86400), "token_type": "bearer"}
@app.get("/me")def me(token: Annotated[str, Depends(oauth2)]): return {"user": read_token(token, "access")}
client = TestClient(app)
@app.post("/refresh")def refresh(body: dict): sub = read_token(body["refresh_token"], "refresh") return {"access_token": make_token(sub, "access", 900)}
USERS["asha"] = hash_password("Orange#2024")tokens = client.post("/token", data={"username": "asha", "password": "Orange#2024"}).json()
expired = make_token("asha", "access", -5)print(client.get("/me", headers={"Authorization": "Bearer " + expired}).status_code)
new = client.post("/refresh", json={"refresh_token": tokens["refresh_token"]}).json()print(client.get("/me", headers={"Authorization": "Bearer " + new["access_token"]}).json())
print(client.post("/refresh", json={"refresh_token": tokens["access_token"]}).json()["detail"])401
{'user': 'asha'}
Wrong token typeWhat this code does
- The expired access token gets 401. The refresh token buys a new access token without a new login.
- An access token used on /refresh is refused by the type check.
Each line breaks one rule of this module. Fix it by reading the key from an environment variable, adding exp and type, and using a salted slow hash with compare_digest.
Finish up
Mark complete & continue
Next: Roles and Permissions