Concepts 🚀 Project 🧮 Labs
📚 Learn / Build OpenBenchML — Step by Step / Forms + Password Hashing + JWT — Login
🚀 Step 4 of 10
← Previous: Step 3 — SQLite + SQLAlchemy — Store Users

Forms + Password Hashing + JWT — Login

Hash passwords (never store plaintext). Issue a signed JWT. Set it as an httpOnly cookie. Protect routes by verifying the cookie.

🎯 Goal — what you'll achieve in this step Register a user, log in, visit /dashboard and see your username. Visit /dashboard without logging in — get redirected to /login.
📖 The basic concept Learn this first. It's the pure Python/HTML idea, stripped of all app complexity.
# The basic concept: a JWT is a signed JSON payload

import jwt, secrets
from datetime import datetime, timedelta, timezone

SECRET = secrets.token_hex(32)  # random 64-char hex string

# Create a token
payload = {
    'sub': 'ada',                              # who is this?
    'exp': datetime.now(timezone.utc) + timedelta(hours=1),  # expires in 1h
}
token = jwt.encode(payload, SECRET, algorithm='HS256')
# token = 'eyJhbGciOiJIUzI1NiIs...'

# Verify the token
try:
    decoded = jwt.decode(token, SECRET, algorithms=['HS256'])
    print(decoded['sub'])  # 'ada'
except jwt.ExpiredSignatureError:
    print('token expired')
except jwt.InvalidTokenError:
    print('bad token')

# The signature proves the token wasn't tampered with.
# Only someone with SECRET can create valid tokens.
🧮 Apply it to OpenBenchML
📄 auth.py python
# Step 4: Password hashing + JWT helpers
import secrets
from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError
from passlib.context import CryptContext

SECRET_KEY = secrets.token_hex(32)  # in production, load from env var
ALGORITHM = 'HS256'
pwd_ctx = CryptContext(schemes=['bcrypt'], deprecated='auto')

def hash_password(plain: str) -> str:
    return pwd_ctx.hash(plain)

def verify_password(plain: str, hashed: str) -> bool:
    return pwd_ctx.verify(plain, hashed)

def make_token(username: str) -> str:
    payload = {
        'sub': username,
        'exp': datetime.now(timezone.utc) + timedelta(hours=24),
    }
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)

def decode_token(token: str) -> str | None:
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        return payload['sub']
    except JWTError:
        return None
📄 main.py python
# Step 4: Add /login + /dashboard on top of steps 1-3
from fastapi import FastAPI, Request, Form, Depends, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session

from database import User, SessionLocal
from auth import hash_password, verify_password, make_token, decode_token

# ... (keep /health, /, /register from previous steps) ...

@app.post('/register')
async def register(
    username: str = Form(...),
    password: str = Form(...),
    db: Session = Depends(get_db),
):
    existing = db.query(User).filter(User.username == username).first()
    if existing:
        return {'error': 'username taken'}
    # NOW we hash the password (step 3 stored plaintext — never do that!)
    user = User(username=username, password_hash=hash_password(password))
    db.add(user); db.commit()
    return RedirectResponse(url='/', status_code=303)

@app.get('/login', response_class=HTMLResponse)
async def login_form(request: Request):
    return templates.TemplateResponse('login.html', {'request': request})

@app.post('/login')
async def login(
    request: Request,
    username: str = Form(...),
    password: str = Form(...),
    db: Session = Depends(get_db),
):
    user = db.query(User).filter(User.username == username).first()
    if not user or not verify_password(password, user.password_hash):
        return {'error': 'bad credentials'}
    # Issue a JWT and set it as an httpOnly cookie
    token = make_token(user.username)
    response = RedirectResponse(url='/dashboard', status_code=303)
    response.set_cookie(
        'access_token', token,
        httponly=True,      # JS can't read it — XSS-safe
        samesite='lax',     # CSRF defence
        max_age=86400,      # 24 hours
    )
    return response

@app.get('/dashboard', response_class=HTMLResponse)
async def dashboard(request: Request, db: Session = Depends(get_db)):
    # Read the cookie, verify the JWT
    token = request.cookies.get('access_token')
    username = decode_token(token) if token else None
    if not username:
        return RedirectResponse(url='/login?next=/dashboard', status_code=303)
    user = db.query(User).filter(User.username == username).first()
    return templates.TemplateResponse('dashboard.html', {
        'request': request, 'user': user,
    })
📄 templates/login.html html
{% extends 'base.html' %}
{% block title %}Login{% endblock %}
{% block content %}
  <h1>Login</h1>
  <form method='post' action='/login'>
    <input type='text' name='username' placeholder='Username' required>
    <input type='password' name='password' placeholder='Password' required>
    <button type='submit'>Log In</button>
  </form>
  <p>No account? <a href='/register'>Register</a></p>
{% endblock %}

How it works

Auth has three layers: (1) Password hashing — bcrypt turns 'secret' into '$2b$12$N9q...' (a salted hash). You can't reverse it. On login, hash the submitted password the same way and compare. Even if your DB leaks, attackers can't recover the plaintext passwords. (2) JWT — after login, the server creates a signed JSON payload {sub: username, exp: +24h}. The signature (HS256 + SECRET_KEY) proves the token wasn't tampered with. Only someone with SECRET_KEY can forge tokens. (3) httpOnly cookie — the JWT is stored in a cookie that JavaScript cannot read. This defeats XSS token theft: even if an attacker injects JS into your page, they can't steal the cookie. Every request automatically sends the cookie, so the server just decodes it to identify the user. The /dashboard route reads the cookie, verifies the JWT, and redirects to /login if anything fails.

✏️ Try yourself Add a /logout route that deletes the cookie: `response.delete_cookie('access_token')`. Add a 'Log Out' link to base.html that only shows when the user is logged in (pass a `user` variable to every template).
⚠️ Common mistakes to avoid
  • Storing plaintext passwords — NEVER do this. Always `hash_password(password)` before saving. If your DB leaks, every user is compromised.
  • Using a short or hardcoded SECRET_KEY — if it's 'secret' or 'changeme', attackers can forge tokens. Use `secrets.token_hex(32)` and load from env var in prod.
  • Missing `httponly=True` on the cookie — without it, `document.cookie` in JS can read the JWT. Any XSS = account takeover.
  • Not handling token expiry — `jwt.decode` raises `ExpiredSignatureError`. Catch it and treat as 'not logged in', not a 500.
🔍 Where this is used in OpenBenchML app/routes/auth.py implements register/login/logout. app/services/auth_service.py has hash/verify/make_token/decode. get_current_user_from_cookie is the dependency used on every protected route.
Next — Step 5
File Upload + Pickle — Upload a Model
→