Login, Hashed Passwords, JWT and Roles
35 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)An API that stores user data must know who is calling. The user logs in once with a password and receives a signed token. Every later request carries the token, so the password never travels again. This lesson shows the simple, safe version.
Never store a password, store its hash
A password hash is the result of putting a password through a one-way function. You cannot turn it back into the password. A random salt is mixed in, so equal passwords still give different hashes.
If your database leaks, plain passwords are lost for good. So you store a hash, a one-way scramble of the password. At login you scramble what the user typed and compare the two.
from argon2 import PasswordHasherfrom argon2.exceptions import VerifyMismatchError
hasher = PasswordHasher()stored = hasher.hash("S3cret-pass") # save this, never the password
try: hasher.verify(stored, "S3cret-pass") print("welcome")except VerifyMismatchError: print("wrong password")Example 1 · Why a hash needs a salt
import hashlibimport hmac
def hash_password(password, salt): return hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 100_000).hex()
salt_a = bytes.fromhex("a1b2c3d4e5f60718")salt_b = bytes.fromhex("0102030405060708")stored = hash_password("S3cret-pass", salt_a)
print(len(stored))print(stored == hash_password("S3cret-pass", salt_b))print(hmac.compare_digest(stored, hash_password("S3cret-pass", salt_a)))print(hmac.compare_digest(stored, hash_password("wrong", salt_a)))64 False True False
What this code does
pbkdf2_hmacrepeats SHA-256 100,000 times on the password and the salt. The result is 32 bytes, which is 64 hex characters.- The same password with another salt gives a different hash. That is the job of a salt.
hmac.compare_digestcompares two texts in constant time. Use it for secrets instead of==.
Install it with python -m pip install argon2-cffi. Argon2 adds a random salt, so two users with the same password get different hashes. It is also deliberately slow, which makes guessing expensive.
A signed token instead of the password
A JWT (JSON Web Token) is a short text with three parts: a header, a payload of claims such as the user and an expiry time, and a signature made with a secret. A changed payload no longer matches its signature.
After a good login the server hands back a token. The client sends it with each request, so the server knows who is calling. The token carries the user name, the role and an expiry time, and is signed with a secret.
import jwtfrom datetime import datetime, timedelta, timezone
expires = datetime.now(timezone.utc) + timedelta(minutes=30)claims = {"sub": "asha", "role": "learner", "exp": expires}token = jwt.encode(claims, SECRET, algorithm="HS256")
data = jwt.decode(token, SECRET, algorithms=["HS256"])print(data["role"])Example 2 · A JWT built by hand
import base64import hashlibimport hmacimport json
SECRET = b"a-long-random-secret-from-the-environment"
def b64(data): return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def unb64(text): return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def sign(text): return b64(hmac.new(SECRET, text.encode(), hashlib.sha256).digest())
def make_token(claims): header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) payload = b64(json.dumps(claims).encode()) return f"{header}.{payload}.{sign(header + '.' + payload)}"
token = make_token({"sub": "asha", "role": "learner", "exp": 1893456000})header, payload, signature = token.split(".")print(json.loads(unb64(payload)))print(hmac.compare_digest(signature, sign(header + "." + payload)))
forged = b64(json.dumps({"sub": "asha", "role": "admin", "exp": 1893456000}).encode())print(hmac.compare_digest(signature, sign(header + "." + forged))){'sub': 'asha', 'role': 'learner', 'exp': 1893456000}
True
FalseWhat this code does
- A token is three base64 pieces joined by dots: header, payload and signature. The signature is an HMAC of the first two pieces.
- The payload prints in clear text, so a JWT is signed but not secret.
- Changing the role to
adminchanges the payload, so the old signature no longer matches and the check givesFalse.
Install it with python -m pip install PyJWT. decode checks the signature and the expiry, and raises an error when either is wrong. The token is signed, not hidden: anyone can read it, so never put a password in it.
Log in and find the current user
Authentication is proving who you are, for example by logging in with a password. It answers "who is calling?". A failed check gives 401. Authorization, the next step, decides what that caller may do.
Two small pieces do the work. /login checks the password and returns a token. current_user reads the token from the Authorization: Bearer header on every protected route.
@app.post("/login")def login(form: OAuth2PasswordRequestForm = Depends()): user = USERS.get(form.username) if user is None or not check_password(user["password_hash"], form.password): raise HTTPException(status_code=401, detail="Wrong username or password") return {"access_token": make_token(form.username, user["role"]), "token_type": "bearer"}
def current_user(token: str = Depends(oauth2)): try: return jwt.decode(token, SECRET, algorithms=["HS256"]) except jwt.InvalidTokenError: raise HTTPException(status_code=401, detail="Invalid or expired token")Example 3 · Argon2 and PyJWT in practice
from datetime import datetime, timedelta, timezone
import jwtfrom argon2 import PasswordHasherfrom argon2.exceptions import VerifyMismatchError
SECRET = "a-long-random-secret-from-the-environment"hasher = PasswordHasher()
stored = hasher.hash("S3cret-pass")print(stored.startswith("$argon2id$"))print(stored == hasher.hash("S3cret-pass"))try: hasher.verify(stored, "wrong")except VerifyMismatchError: print("wrong password")
claims = {"sub": "asha", "role": "learner", "exp": datetime.now(timezone.utc) + timedelta(minutes=30)}token = jwt.encode(claims, SECRET, algorithm="HS256")print(token.count("."))print(jwt.decode(token, SECRET, algorithms=["HS256"])["role"])try: jwt.decode(token, "another-secret-of-the-same-length-xx", algorithms=["HS256"])except jwt.InvalidTokenError as error: print(type(error).__name__)True False wrong password 2 learner InvalidSignatureError
What this code does
hasher.hashgives a new text each time because of the random salt, so you check a login withverify, never with==.- A token has two dots.
decodereturns the claims when the signature and expiry are right. - A different secret gives an error that is a kind of
InvalidTokenError, which is what your route catches.
Give the same message for an unknown user and a wrong password, so attackers cannot find out which names exist. The login form needs python -m pip install python-multipart. Read SECRET from an environment variable and never commit it.
Roles decide what a user may do
A role is a label on a user, such as learner or admin, that says which actions are allowed. Checking it before a route runs is authorization. A wrong role gets 403, a missing login gets 401.
A role is a label such as learner or admin stored with the user and copied into the token. A small dependency compares it with what the route needs and answers 403 when it does not match.
def require_role(role): def check(user: dict = Depends(current_user)): if user["role"] != role: raise HTTPException(status_code=403, detail="Not allowed") return user return check
@app.get("/admin/report")def report(user: dict = Depends(require_role("admin"))): return {"report": "all orders"}Example 4 · Login, a protected route and a role
from datetime import datetime, timedelta, timezone
import jwtfrom argon2 import PasswordHasherfrom argon2.exceptions import VerifyMismatchErrorfrom fastapi import Depends, FastAPI, HTTPExceptionfrom fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestFormfrom fastapi.testclient import TestClient
SECRET = "a-long-random-secret-from-the-environment"hasher = PasswordHasher()USERS = { "asha": {"password_hash": hasher.hash("pass1234"), "role": "learner"}, "ravi": {"password_hash": hasher.hash("admin1234"), "role": "admin"},}app = FastAPI()oauth2 = OAuth2PasswordBearer(tokenUrl="/login")
def check_password(stored_hash, password): try: return hasher.verify(stored_hash, password) except VerifyMismatchError: return False
def make_token(username, role): expires = datetime.now(timezone.utc) + timedelta(minutes=30) return jwt.encode({"sub": username, "role": role, "exp": expires}, SECRET, algorithm="HS256")
@app.post("/login")def login(form: OAuth2PasswordRequestForm = Depends()): user = USERS.get(form.username) if user is None or not check_password(user["password_hash"], form.password): raise HTTPException(status_code=401, detail="Wrong username or password") return {"access_token": make_token(form.username, user["role"]), "token_type": "bearer"}
def current_user(token: str = Depends(oauth2)): try: return jwt.decode(token, SECRET, algorithms=["HS256"]) except jwt.InvalidTokenError: raise HTTPException(status_code=401, detail="Invalid or expired token")
def require_role(role): def check(user: dict = Depends(current_user)): if user["role"] != role: raise HTTPException(status_code=403, detail="Not allowed") return user return check
@app.get("/me")def me(user: dict = Depends(current_user)): return {"username": user["sub"], "role": user["role"]}
@app.get("/admin/report")def report(user: dict = Depends(require_role("admin"))): return {"report": "all orders"}
client = TestClient(app)
def login_as(username, password): response = client.post("/login", data={"username": username, "password": password}) return response, {"Authorization": "Bearer " + response.json().get("access_token", "")}
print(login_as("asha", "nope")[0].status_code)_, asha = login_as("asha", "pass1234")_, ravi = login_as("ravi", "admin1234")print(client.get("/me").status_code)print(client.get("/me", headers=asha).json())print(client.get("/admin/report", headers=asha).status_code)print(client.get("/admin/report", headers=ravi).json())401
401
{'username': 'asha', 'role': 'learner'}
403
{'report': 'all orders'}What this code does
- A wrong password gives 401. A request with no token also gives 401, because
OAuth2PasswordBearerfinds no header. - The learner reaches
/mebut is refused on/admin/reportwith 403. The admin gets the report. require_role("admin")builds a dependency, so any route can ask for a role in one line.
401 means "I do not know who you are". 403 means "I know who you are, and you may not do this". Always check the role on the server; hiding a button in the page is not protection.
Next step
Check your understanding
10 quick questions