7
Admin Authentication
Session-based login, bcrypt password hashing, and a require_admin dependency that guards every admin route
By the end of this chapter /admin/login accepts a username and password,
stores a signed session cookie, and redirects to an /admin dashboard page.
Any route decorated with Depends(require_admin) automatically redirects
unauthenticated visitors to the login page. The public-facing site is completely unaffected.
1 Design choices — why session cookies, not JWT
There are two mainstream approaches to authentication in FastAPI tutorials: JWT tokens (stateless, used for APIs and SPAs) and session cookies (stateful, used for server-rendered web apps). For osztromok.com's admin, session cookies are the right choice:
| JWT (stateless) | Session cookie (stateful) |
|---|---|
| Token stored in JS/localStorage | Signed cookie — browser handles it automatically |
| Needs JS to attach to every request | Browser sends cookie with every request automatically |
| Can't be invalidated server-side until expiry | Clear the session → user is logged out instantly |
| Right for: REST APIs, mobile apps, SPAs | Right for: server-rendered HTML apps (this site) |
Starlette (which FastAPI is built on) ships SessionMiddleware —
it encrypts and signs a cookie using a secret key. The session is a plain Python dict
available as request.session in any route. We store one value in it:
request.session["admin_logged_in"] = True.
For passwords we use bcrypt via the passlib library.
The admin password is stored as a bcrypt hash in .env — never as
plain text. This is a personal admin panel for one user, so a single
username/password pair in .env is the right level of complexity.
No admin user table needed.
2 Add passlib to requirements.txt
SessionMiddleware is already in starlette (installed
with FastAPI). The only new package is passlib for password hashing.
The [bcrypt] extra installs the C-accelerated bcrypt backend.
fastapi
uvicorn[standard]
jinja2
sqlalchemy
pymysql
python-multipart
python-dotenv
itsdangerous
passlib[bcrypt] # ← new: bcrypt password hashing
(venv) $ pip install passlib[bcrypt]
SessionMiddleware uses internally to sign the cookie. You don't
import it directly; Starlette uses it automatically when you provide a secret_key.
3 Add credentials to .env and generate a hash
Three new values go into .env. You generate the password hash once
using a small helper script, then paste the output in.
DB_HOST=localhost
DB_USER=your_db_user
DB_PASS=your_db_password
DB_NAME=your_db_name
SECRET_KEY=replace-this-with-a-long-random-string
ADMIN_USERNAME=philip
ADMIN_PASSWORD_HASH=# paste the output of generate_hash.py here
Generating a strong SECRET_KEY
(venv) $ python -c "import secrets; print(secrets.token_hex(32))" # Example output: 9f4a8e2b1c7d0e5f3a6b9c2d5e8f1a4b7c0d3e6f9a2b5c8d1e4f7a0b3c6d9e2
Copy the output into SECRET_KEY= in your .env.
Generating the bcrypt password hash
Run this script once, copy the output, then delete the script. Never commit plain passwords — the script is for local use only.
"""Run this once to create the bcrypt hash for your admin password. Usage: python generate_hash.py Copy the printed hash into ADMIN_PASSWORD_HASH= in .env, then delete this file. """ from passlib.context import CryptContext import getpass pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") password = getpass.getpass("Enter your admin password: ") confirm = getpass.getpass("Confirm: ") if password != confirm: print("Passwords do not match.") else: hashed = pwd_context.hash(password) print(f"\nADMIN_PASSWORD_HASH={hashed}") print("\nPaste the line above into your .env file.")
(venv) $ python generate_hash.py
Enter your admin password:
Confirm:
ADMIN_PASSWORD_HASH=$2b$12$XvZ9kL2mQ8rT4pW6nY1uOeK3jF5hG7sD0cB9aE4iN6wM2vX8yZ0u
Paste the line above into your .env file.
.env and verify against it with
pwd_context.verify(plain, hash). Don't regenerate the hash on every
server restart — that would invalidate existing sessions.
4 Add SessionMiddleware to main.py
Middleware wraps the entire application. It must be added to app
before any route or router is registered. Place it near the top of
main.py, right after the app is created.
import os from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware # ← new from dotenv import load_dotenv from app.routers import cms, special, auth # ← add auth load_dotenv() app = FastAPI() # ── Session middleware ──────────────────────────────────────────────────── # Must be added before any routes. Uses itsdangerous internally to sign the # cookie so it can't be tampered with on the client side. app.add_middleware( SessionMiddleware, secret_key=os.getenv("SECRET_KEY"), session_cookie="osztromok_session", # custom cookie name (optional) max_age=86400 * 7, # 7 days same_site="lax", # CSRF protection: lax is fine for HTML forms https_only=False, # set True in production (after SSL cert) ) # Static mounts and routers unchanged from previous chapters... app.mount("/static", StaticFiles(directory="app/static"), name="static") app.mount("/resources", StaticFiles(directory="resources"), name="resources") app.include_router(auth.router) # ← register auth routes first (before CMS catch-alls) app.include_router(special.router) app.include_router(cms.router)
True. Running with https_only=False in production
would allow the session cookie to be sent over unencrypted connections — always
set True on the live server.
5 Creating app/routers/auth.py
The auth router handles four routes: the login page (GET), the login form submission
(POST), the logout action (GET), and the admin dashboard stub (GET). All routes
are under the /admin prefix.
import os from fastapi import APIRouter, Depends, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from passlib.context import CryptContext from app.dependencies import CommonContext, require_admin router = APIRouter(prefix="/admin") templates = Jinja2Templates(directory="app/templates") # Bcrypt context — same settings as in generate_hash.py pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") # Read credentials from environment (loaded from .env by main.py) ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin") ADMIN_PASSWORD_HASH = os.getenv("ADMIN_PASSWORD_HASH", "") # ── GET /admin/login — show the login form ─────────────────────────────── @router.get("/login", response_class=HTMLResponse) async def login_page(request: Request, ctx: CommonContext = Depends()): # Already logged in? Skip the form and go straight to the dashboard if request.session.get("admin_logged_in"): return RedirectResponse(url="/admin", status_code=302) return templates.TemplateResponse( "admin/login.html", ctx.get(error=None), ) # ── POST /admin/login — validate and set session ───────────────────────── @router.post("/login") async def login_submit( request: Request, username: str = Form(...), password: str = Form(...), ctx: CommonContext = Depends(), ): # Constant-time comparison: verify hash even if username is wrong. # This prevents timing attacks that distinguish "wrong user" from "wrong password". username_ok = username == ADMIN_USERNAME password_ok = pwd_context.verify(password, ADMIN_PASSWORD_HASH) if ADMIN_PASSWORD_HASH else False if username_ok and password_ok: request.session["admin_logged_in"] = True return RedirectResponse(url="/admin", status_code=302) # Render login form again with a generic error message. # Generic message: don't reveal whether username or password was wrong. return templates.TemplateResponse( "admin/login.html", ctx.get(error="Invalid username or password."), status_code=401, ) # ── GET /admin — dashboard (protected) ─────────────────────────────────── @router.get("/", response_class=HTMLResponse) async def admin_dashboard( _: None = Depends(require_admin), # ← enforces login ctx: CommonContext = Depends(), ): return templates.TemplateResponse("admin/dashboard.html", ctx.get()) # ── GET /admin/logout — clear session and redirect home ────────────────── @router.get("/logout") async def logout(request: Request): request.session.clear() return RedirectResponse(url="/", status_code=302)
6 The require_admin dependency
Add require_admin to app/dependencies.py alongside
CommonContext. It's a simple async function — FastAPI treats any
callable as a dependency. When the session flag is missing, it raises
HTTPException with a redirect status code.
from fastapi import Depends, HTTPException, Request from app.database import get_db, get_subjects from sqlalchemy.orm import Session # ── CommonContext (unchanged from Chapter 5) ───────────────────────────── class CommonContext: def __init__(self, request: Request, db: Session = Depends(get_db)): self.request = request self.db = db self._subjects = None @property def subjects(self): if self._subjects is None: self._subjects = get_subjects(self.db) return self._subjects @property def active_subject(self) -> str: parts = self.request.url.path.strip("/").split("/") return parts[0] if parts and parts[0] else "" def get(self, **extra) -> dict: return { "request": self.request, "subjects": self.subjects, "active_subject": self.active_subject, **extra, } # ── require_admin — the protected-route dependency ─────────────────────── async def require_admin(request: Request) -> None: """Dependency that redirects to /admin/login if the admin session is absent. Usage in any route: _: None = Depends(require_admin) FastAPI injects the same Request object here and in CommonContext — no duplicate DB calls or request parsing. """ if not request.session.get("admin_logged_in"): raise HTTPException( status_code=302, headers={"location": "/admin/login"}, )
How to use it in any route
# The underscore _ signals "I don't use the return value — just run the check" @router.get("/subjects/new", response_class=HTMLResponse) async def new_subject_form( _: None = Depends(require_admin), ctx: CommonContext = Depends(), ): return templates.TemplateResponse("admin/subject_form.html", ctx.get()) # No if/else needed — if the session is invalid, require_admin raises # an HTTPException(302) before the function body even executes.
@PreAuthorize("isAuthenticated()") or configure an
HttpSecurity rule. FastAPI's Depends(require_admin) is
the equivalent — declare it on the route and the framework calls it before your
handler runs. The key difference is FastAPI's dependencies are plain Python
functions, not annotations backed by an AOP proxy.
7 Admin templates
Create a new app/templates/admin/ folder. Admin templates inherit
from base.html like all other templates, so the site nav and footer
appear automatically. The login page is an exception — it has no nav and no footer,
just the centred form.
app/templates/admin/login.html
{% extends "base.html" %} {% block title %}Admin Login — osztromok.com{% endblock %} {% block extra_head %} <style> body { background: #0d1117; } .login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; } .login-card { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 36px 40px; width: 100%; max-width: 380px; } .login-card h1 { font-size: 1.3rem; font-weight: 700; color: #e6edf3; margin: 0 0 6px; } .login-card p { color: #8b949e; font-size: 0.88rem; margin: 0 0 24px; } .login-field { margin-bottom: 16px; } .login-field label { display: block; font-size: 0.8rem; font-weight: 600; color: #8b949e; margin-bottom: 5px; letter-spacing: 0.06em; text-transform: uppercase; } .login-field input { width: 100%; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 9px 12px; color: #e6edf3; font-size: 0.92rem; box-sizing: border-box; transition: border-color 0.15s; } .login-field input:focus { outline: none; border-color: #bc8cff; } .login-btn { width: 100%; background: #bc8cff; color: #000; border: none; border-radius: 6px; padding: 10px; font-weight: 700; font-size: 0.9rem; cursor: pointer; margin-top: 8px; transition: opacity 0.15s; } .login-btn:hover { opacity: 0.88; } .login-error { background: rgba(248,81,73,0.1); border: 1px solid rgba(248,81,73,0.35); color: #ff8882; border-radius: 6px; padding: 9px 12px; font-size: 0.85rem; margin-bottom: 16px; } </style> {% endblock %} {% block content %} <div class="login-wrap"> <div class="login-card"> <h1>Admin Login</h1> <p>osztromok.com content management</p> {% if error %} <div class="login-error">{{ error }}</div> {% endif %} <form method="post" action="/admin/login"> <div class="login-field"> <label for="username">Username</label> <input type="text" id="username" name="username" autocomplete="username" autofocus> </div> <div class="login-field"> <label for="password">Password</label> <input type="password" id="password" name="password" autocomplete="current-password"> </div> <button type="submit" class="login-btn">Sign In</button> </form> </div> </div> {% endblock %}
app/templates/admin/dashboard.html
A placeholder for now. Chapter 8 replaces this with full CRUD links. The important part is it works — only authenticated visitors can reach it.
{% extends "base.html" %} {% block title %}Admin Dashboard — osztromok.com{% endblock %} {% block content %} <div style="max-width:800px;margin:60px auto;padding:0 24px"> <h1 style="font-size:1.6rem;color:#e6edf3;margin-bottom:8px"> Admin Dashboard </h1> <p style="color:#8b949e;margin-bottom:32px"> You are logged in. CRUD interface coming in Chapter 8. </p> <!-- Quick-link cards — Chapter 8 will link these to real pages --> <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:32px"> <div style="background:#161b22;border:1px solid #30363d;border-radius:8px;padding:20px;opacity:.5"> <div style="font-size:1.5rem;margin-bottom:8px">📚</div> <strong style="color:#e6edf3">Subjects</strong> <p style="color:#6e7681;font-size:.8rem;margin:4px 0 0">Chapter 8</p> </div> <div style="background:#161b22;border:1px solid #30363d;border-radius:8px;padding:20px;opacity:.5"> <div style="font-size:1.5rem;margin-bottom:8px">🗂️</div> <strong style="color:#e6edf3">Subtopics</strong> <p style="color:#6e7681;font-size:.8rem;margin:4px 0 0">Chapter 8</p> </div> <div style="background:#161b22;border:1px solid #30363d;border-radius:8px;padding:20px;opacity:.5"> <div style="font-size:1.5rem;margin-bottom:8px">📄</div> <strong style="color:#e6edf3">Pages</strong> <p style="color:#6e7681;font-size:.8rem;margin:4px 0 0">Chapter 8</p> </div> </div> <a href="/admin/logout" style="color:#f85149;font-size:.88rem;text-decoration:none"> Sign out → </a> </div> {% endblock %}
8 New files added this chapter
osztromok/ ├── .env ← + SECRET_KEY, ADMIN_USERNAME, ADMIN_PASSWORD_HASH ├── generate_hash.py ← new (run once, then delete) ├── requirements.txt ← + passlib[bcrypt] └── app/ ├── main.py ← + SessionMiddleware, auth router ├── dependencies.py ← + require_admin function ├── routers/ │ ├── auth.py ← new: login / logout / dashboard │ ├── special.py (unchanged) │ └── cms.py (unchanged) └── templates/ └── admin/ ← new folder ├── login.html ← new └── dashboard.html ← new (placeholder)
9 Testing the authentication flow
(venv) $ uvicorn app.main:app --reload
Work through this checklist in your browser:
- GET /admin/login — login form appears (not the dashboard)
- Submit with wrong password → error message appears, form stays on page, status 401
- Submit with correct credentials → redirected to /admin with dashboard
- GET /admin/login again while logged in → redirected to /admin automatically
- GET /admin/logout → redirected to home
/, session cleared - Try GET /admin directly while logged out → redirected to /admin/login
- Check DevTools → Application → Cookies — see
osztromok_sessioncookie is present while logged in - After logout, the cookie should be gone (or its value should be an empty signed payload)
Checking the session cookie in DevTools
osztromok_session cookie value is a base64-encoded string that
itsdangerous has cryptographically signed. It is not encrypted — you can
decode the base64 and see the JSON inside. But it is tamper-proof: if
anyone edits the cookie, the signature verification fails and the session is
treated as empty. This is why SECRET_KEY must be kept secret and
random — it's what makes the signature trustworthy.
What if SECRET_KEY is missing from .env?
os.getenv("SECRET_KEY") returns None, Starlette will
raise ValueError: 'secret_key' must not be None at startup —
the server won't start at all. This is intentional: running with no secret key
would mean every session cookie was signed with the same empty key, making them
trivially forgeable. Always set SECRET_KEY before starting the server.
10 Security considerations
For a personal CMS on a low-traffic personal site, the implementation above is appropriate. A few things worth understanding:
-
Brute-force protection — there is no rate limiting on the login
endpoint. For a public internet site, adding something like
slowapi(a rate-limiter for FastAPI) would prevent automated password guessing. For a personal site behind a VPN or with a strong password, this is low-risk. -
CSRF protection — the
same_site="lax"setting onSessionMiddlewareprovides defence against Cross-Site Request Forgery. Modern browsers will not send the session cookie on cross-origin form submissions. For extra protection, Chapter 8 will add a hidden CSRF token to admin forms. - https_only=True in production — set this before going live in Chapter 10. Without it, the session cookie can be transmitted over plain HTTP, making it vulnerable to interception on the network.
-
Timing attacks — the login handler always calls
pwd_context.verify()regardless of whether the username matched. This ensures the response time is the same for a wrong username as for a wrong password, preventing attackers from inferring which was wrong. -
The password hash in .env — this file must never be committed
to a public git repository. Confirm that
.envis in your.gitignore.
.env __pycache__/ *.pyc venv/ .venv/
✓ Chapter 7 Complete — Milestone reached
- SessionMiddleware added to main.py — signs cookies with SECRET_KEY from .env
- generate_hash.py — one-time script to produce a bcrypt hash for the admin password
- app/routers/auth.py — GET/POST /admin/login, GET /admin/logout, GET /admin (dashboard stub)
- require_admin dependency — a plain async function in dependencies.py; raises HTTPException(302) if session is absent
- Post/Redirect/Get pattern — successful login redirects rather than returning HTML directly
- Timing-safe login check — always verifies the hash even on a wrong username
- admin/login.html — standalone centred form with error display, purple accent to match the admin theme
- admin/dashboard.html — placeholder with greyed-out Chapter 8 cards, sign-out link
- All public-facing routes are completely unaffected — visitors see no login prompts or session cookies