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.
# 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.
# 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
# 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,
})
{% 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.
- 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.