The Brief
3 minAdd a complete, secure auth + authorization layer to a Flask blog:
- Registration & login — bcrypt-hashed passwords (Lesson 13), rate-limited login (Lesson 37).
- Secure sessions — random IDs, HttpOnly/Secure/SameSite cookies, logout, expiry.
- RBAC — admin / editor / reader roles with permission + ownership checks (Lesson 40).
- Hardening — parameterised queries, output escaping, CSP, secrets in env, debug off.
It's the synthesis of the web-security arc: the same blog that had bugs in the OWASP lessons, now built right. Run it locally; it's the foundation for any real authenticated app you ship.
The Security Plan
5 minFeature Security control (lesson) register/login bcrypt hashing (L8-13) + rate limit (L8-37) sessions random IDs + HttpOnly/Secure/SameSite cookies (L8-37) roles RBAC: permissions → roles, @require decorator (L8-40) edit/delete post role check AND ownership check (L8-28, 40) all DB access parameterised queries (L8-31) all output auto-escaped templates + CSP (L8-33) config secrets in env, debug=False (L8-29, 34)
Don't bolt security on at the end — wire each control in as you build the feature. The plan above maps every feature to its control; work through it and the app is secure by construction, not by afterthought.
Build It · Auth & Sessions
14 minSecure config + DB
import os, time, secrets, functools, bcrypt, sqlite3 from flask import Flask, request, session, redirect, abort, render_template_string app = Flask(__name__) app.secret_key = os.environ["SECRET_KEY"] # from env (L8-29) app.config.update(SESSION_COOKIE_HTTPONLY=True, # L8-37 SESSION_COOKIE_SECURE=True, SESSION_COOKIE_SAMESITE="Lax") db = sqlite3.connect("blog.db", check_same_thread=False) db.executescript(""" CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT UNIQUE, pw BLOB, role TEXT); CREATE TABLE IF NOT EXISTS posts (id INTEGER PRIMARY KEY, author_id INTEGER, body TEXT); """) @app.after_request def csp(resp): # L8-33 resp.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'" return resp
Register & login (bcrypt + rate limit)
from collections import defaultdict attempts = defaultdict(list) @app.post("/register") def register(): name, pw = request.form["name"], request.form["pw"].encode() hashed = bcrypt.hashpw(pw, bcrypt.gensalt()) # L8-13 try: db.execute("INSERT INTO users (name, pw, role) VALUES (?, ?, ?)", (name, hashed, "reader")) # role forced server-side! (L8-40) db.commit() except sqlite3.IntegrityError: return "username taken", 409 return "registered", 201 @app.post("/login") def login(): ip = request.remote_addr; now = time.time() attempts[ip] = [t for t in attempts[ip] if t > now - 300] if len(attempts[ip]) >= 5: # rate limit (L8-37) return "too many attempts", 429 attempts[ip].append(now) name, pw = request.form["name"], request.form["pw"].encode() row = db.execute("SELECT id, pw, role FROM users WHERE name = ?", (name,)).fetchone() # parameterised (L8-31) if not row or not bcrypt.checkpw(pw, row[1]): # constant-ish work return "invalid credentials", 401 session.clear() # regenerate (no fixation) session["uid"], session["role"] = row[0], row[2] return "ok"
Build It · RBAC, Ownership & Safe Output
12 minRBAC decorator + ownership
ROLE_PERMISSIONS = { "reader": {"post:read"}, "editor": {"post:read", "post:create", "post:edit"}, "admin": {"post:read", "post:create", "post:edit", "post:delete", "user:manage"}, } def has(perm): return perm in ROLE_PERMISSIONS.get(session.get("role"), set()) def require(permission): def deco(fn): @functools.wraps(fn) def w(*a, **k): if "uid" not in session: abort(401) if not has(permission): abort(403) # deny by default (L8-40) return fn(*a, **k) return w return deco def get_owned_post(pid): row = db.execute("SELECT id, author_id, body FROM posts WHERE id=?", (pid,)).fetchone() # role check happened via @require; this is the OWNERSHIP check (L8-28) if not row or (row[1] != session["uid"] and session.get("role") != "admin"): abort(404) return row @app.post("/post") @require("post:create") def create_post(): db.execute("INSERT INTO posts (author_id, body) VALUES (?, ?)", (session["uid"], request.form["body"])) # parameterised db.commit(); return "created", 201 @app.post("/post/<int:pid>/edit") @require("post:edit") def edit_post(pid): get_owned_post(pid) # 404 if not yours/admin db.execute("UPDATE posts SET body=? WHERE id=?", (request.form["body"], pid)); db.commit() return "edited" @app.delete("/post/<int:pid>") @require("post:delete") # admins only def delete_post(pid): db.execute("DELETE FROM posts WHERE id=?", (pid,)); db.commit() return "deleted"
Safe output (auto-escaped templates)
@app.get("/posts") @require("post:read") def list_posts(): rows = db.execute("SELECT body FROM posts").fetchall() # render_template_string auto-escapes {{ }} → no XSS (L8-33) return render_template_string( "<h1>Posts</h1>{% for b in bodies %}<p>{{ b }}</p>{% endfor %}", bodies=[r[0] for r in rows])
reader → can read posts; can't create/edit/delete (403) editor → can create + edit OWN posts; editing another's → 404; can't delete (403) admin → full access incl. delete + user:manage all posts render escaped (a <script> in a post body shows as text) login rate-limited; passwords bcrypt; cookies HttpOnly/Secure/SameSite; debug off
Read the result
Every control from the plan is wired into the feature it protects: bcrypt + rate-limited login, server-decided roles (no self-promotion), the @require permission gate plus ownership checks (closing IDOR), parameterised queries everywhere (no SQLi), auto-escaped templates + CSP (no XSS), and secure config (secrets in env, debug off). The same blog that was riddled with OWASP bugs in Lessons 28-36 is now secure by construction. This is the real shape of an authenticated web app — and exactly what the capstone scanner (Lesson 47) would give a clean bill of health.
Build It Yourself
13 minImplement register (bcrypt, role forced to reader) and login (parameterised, rate-limited, session regenerated). Confirm a registered user can log in and a wrong password / 6th rapid attempt is rejected.
Add the roles, the @require decorator, and ownership checks. Test the full matrix: reader/editor/admin against create/edit-own/edit-others/delete. Confirm every cell matches the expected allow/deny.
Throw the Lesson 28-36 attacks at your secured app: SQLi in login, IDOR on edit, XSS in a post, an unprotected admin route, a self-assign-role registration. Confirm every attack now fails, and document each defence that stopped it.
Stretch · Add a Token API & Audit Log
8 minTwo production extensions: (1) add a JWT-protected API endpoint (Lesson 38) so a mobile client can authenticate without cookies; and (2) add an audit log (preview of Lesson 45) recording every privileged action — who did what, when, from where — so you can investigate later.
Show the key additions
import jwt, logging, os audit = logging.getLogger("audit") # (1) issue a JWT on API login; verify on API routes (L8-38) def make_jwt(uid, role): import datetime return jwt.encode({"sub": uid, "role": role, "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=15)}, os.environ["JWT_SECRET"], algorithm="HS256") # (2) audit every privileged action (L8-45) def log_action(action, target): audit.info("user=%s role=%s action=%s target=%s ip=%s", session.get("uid"), session.get("role"), action, target, request.remote_addr) # in delete_post: log_action("post:delete", pid) before deleting
Non-negotiables: a verified-JWT API route, and an audit log of privileged actions with who/what/when/where.
Recap
3 minYou secured a real app by mapping every feature to its control and building it in from the start: bcrypt + rate-limited login (12-13, 37), hardened sessions (37), RBAC with permission and ownership checks (28, 40), parameterised queries (31), auto-escaped output + CSP (33), and secure config — secrets in env, debug off (29, 34). The same blog that was full of OWASP bugs is now secure by construction, and the prior attacks all fail. Stretch it with a JWT API and an audit log and you have the shape of a production authenticated service — the thing the capstone scanner (Lesson 47) verifies.
Vocabulary Card
- secure by construction
- Building controls into each feature, not bolting them on later.
- auth layer
- Login + session management + authorization, working together.
- role + ownership
- Both checks combined to fully close the access-control gap.
- defence in depth
- Many independent controls so one failure isn't fatal.
Homework
4 minBuild the secured blog with auth + RBAC and all the hardening. Re-run the OWASP attack suite (28-36) and prove each fails, documenting the control that stopped it. Add one stretch (JWT API or audit log). Write a short security summary mapping each control to the OWASP category and lesson it addresses — your project's "security posture" document.
Sample · security posture summary
SECURITY POSTURE — secured blog
Attack (from L8-28..36) → Control that stops it OWASP
SQLi in /login → parameterised queries (L8-31) A03 ✓
IDOR on /post/<id>/edit → ownership check + 404 (L8-28,40) A01 ✓
stored/reflected XSS → auto-escaped templates + CSP (L8-33) A07 ✓
unprotected admin route → @require('user:manage') (L8-40) A01 ✓
self-assign role → role forced 'reader' server-side A01 ✓
weak password storage → bcrypt + salt (L8-13) A02 ✓
brute-force login → 5/5min rate limit + lockout (L8-37) A07 ✓
debug=True / secret in code→ debug off; SECRET_KEY from env (L8-34)A05/A02 ✓
Every prior attack re-tested → all fail. Stretch: JWT-protected
/api/me and an audit log of post:delete / user:manage actions.Non-negotiables: secured blog with auth+RBAC+hardening, the attack suite all failing with documented controls, one stretch, and a posture summary mapping controls to OWASP.