Module 12 · Production Engineering · Drills
Security knowledge that lives only in your head fails under pressure. Type each fix yourself, run it, then reveal the solution. These are the exact tasks you'll be handed in a take-home or a whiteboard.
Drill 1 hashing
Hash the password "hunter2-correct-horse" with argon2, then verify a correct and an incorrect attempt against the stored hash.
from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError ph = PasswordHasher() stored = ph.hash("hunter2-correct-horse") # salt is automatic print(ph.verify(stored, "hunter2-correct-horse")) # True try: ph.verify(stored, "wrong") except VerifyMismatchError: print("rejected") # rejected
Never store the plain password. The hash string already contains the algorithm, salt, and cost parameters — that's all you keep.
Drill 2 sql injection
This query is vulnerable. Spot why, then rewrite it with a parameterized query.
email = request.query["email"] cursor.execute(f"SELECT * FROM users WHERE email = '{email}'")
# The f-string lets input like ' OR '1'='1 become SQL. # Fix: pass the value SEPARATELY as a parameter. cursor.execute( "SELECT * FROM users WHERE email = ?", (email,) ) # Or with the ORM (parameterized for you): user = session.query(User).filter(User.email == email).first()
The driver treats the placeholder value as data, never as part of the SQL — so input can't change the query's structure.
Drill 3 cors
Configure FastAPI CORS so only https://docchat.example.com may call the API with credentials. Avoid the unsafe wildcard.
from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins=["https://docchat.example.com"], # exact origin allow_credentials=True, allow_methods=["GET", "POST"], allow_headers=["Authorization", "Content-Type"], )
allow_origins=["*"] with allow_credentials=True is forbidden by browsers — it would let any website act as your logged-in users.
Drill 4 rate limit
Add a slowapi rate limit of 5 requests per minute per IP to the /ask endpoint.
from slowapi import Limiter from slowapi.util import get_remote_address from fastapi import Request limiter = Limiter(key_func=get_remote_address) app.state.limiter = limiter @app.post("/ask") @limiter.limit("5/minute") def ask(request: Request, q: Question, user=Depends(current_user)): ... # the Request param is required by slowapi
Rate limiting protects the expensive LLM call from abuse and keeps your bill predictable — an "insecure design" fix (OWASP A04).
Drill 5 authorization
Write an ownership check for GET /documents/{doc_id}: the caller may only read their own document. Return 404 if it's missing or not theirs.
@app.get("/documents/{doc_id}") def get_document(doc_id: int, user=Depends(current_user)): doc = db.get_document(doc_id) if doc is None or doc.owner_id != user.id: raise HTTPException(status_code=404, detail="Not found") return doc
Returning 404 instead of 403 hides whether the id even exists, so an attacker can't enumerate other users' documents. Fixing exactly the IDOR from the lesson.
Build · review & fix
The code under review:
# settings JWT_SECRET = "supersecret123" # hardcoded @app.post("/signup") def signup(email, password): db.execute(f"INSERT INTO users VALUES ('{email}', '{password}')") @app.get("/documents/{doc_id}") def get_doc(doc_id, user=Depends(current_user)): return db.get_document(doc_id) app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True)
# 1 Crypto failure: secret hardcoded & in source. # -> load from env via Settings; rotate it; .env in .gitignore. # 2 Crypto failure: password stored in plain text. # -> hash with argon2 before insert: ph.hash(password) # 3 Injection: f-string SQL on signup. # -> parameterize: db.execute("INSERT ... VALUES (?, ?)", (email, pw_hash)) # 4 Broken access control (IDOR): no ownership check on get_doc. # -> if doc is None or doc.owner_id != user.id: raise 404 # 5 Security misconfig: CORS "*" with credentials. # -> allow_origins=[settings.frontend_origin] # Hardened version: @app.post("/signup") def signup(body: SignUp): pw_hash = ph.hash(body.password) db.execute("INSERT INTO users (email, pw_hash) VALUES (?, ?)", (body.email, pw_hash)) @app.get("/documents/{doc_id}") def get_doc(doc_id: int, user=Depends(current_user)): doc = db.get_document(doc_id) if doc is None or doc.owner_id != user.id: raise HTTPException(status_code=404, detail="Not found") return doc
Bonus issues if you spotted them: no rate limit on signup (brute-force / spam), no Pydantic validation on the inputs, and no length bound on the password. Naming extra issues is what separates a mid from a senior in review.
Click a card to flip it. Say the answer out loud before you flip — these are the ones interviewers ask verbatim.
argon2 or bcrypt. Never plain text, never md5/sha1.httpOnly, Secure, SameSite cookie — not localStorage (XSS can read it).{values} as text. The trap is dangerouslySetInnerHTML — sanitize first.pip-audit for Python, npm audit for the front-end. Wire into CI.Tick each only if you can do it without looking:
"*" + credentials is forbidden