7

FastAPI Rebuild › Chapter 7

Admin Authentication

Session-based login, bcrypt password hashing, and a require_admin dependency that guards every admin route

Chapter 7 of 10 SessionMiddleware passlib + bcrypt Depends(require_admin)
Chapter Milestone

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/localStorageSigned cookie — browser handles it automatically
Needs JS to attach to every requestBrowser sends cookie with every request automatically
Can't be invalidated server-side until expiryClear the session → user is logged out instantly
Right for: REST APIs, mobile apps, SPAsRight 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.

requirements.txt — add one line text
fastapi
uvicorn[standard]
jinja2
sqlalchemy
pymysql
python-multipart
python-dotenv
itsdangerous
passlib[bcrypt]          # ← new: bcrypt password hashing
terminal
(venv) $ pip install passlib[bcrypt]
itsdangerous is already in requirements.txt from Chapter 1 — it is the library 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.

.env — add these three lines env
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

terminal — one-liner
(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.

generate_hash.py — run once, then delete python
"""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.")
terminal
(venv) $ python generate_hash.py
Enter your admin password:
Confirm:

ADMIN_PASSWORD_HASH=$2b$12$XvZ9kL2mQ8rT4pW6nY1uOeK3jF5hG7sD0cB9aE4iN6wM2vX8yZ0u

Paste the line above into your .env file.
bcrypt hashes are not reversible. The hash changes every time (random salt), so if you hash the same password twice you get two different strings — both correct. Store one hash in .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.

app/main.py — additions (shown in context) python
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)
https_only=False for now. In development, the session cookie works over HTTP. In Chapter 10 (Deployment) you'll enable SSL with certbot and flip this to 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.

GET /admin/loginshow login form
POST /admin/logincheck credentials
GET /admindashboard (protected)
GET /admin/logoutclear session → /
app/routers/auth.py python
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)
Why does POST /admin/login return a 302? After a successful form submission, always redirect. If you return HTML directly on a POST response, hitting the browser's back/refresh button re-submits the form — which re-logs in and is confusing. The redirect forces a GET request for the dashboard, which is safe to refresh. This pattern is called Post/Redirect/Get (PRG).

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.

app/dependencies.py — add require_admin below CommonContext python
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

Pattern for any protected admin route (Chapter 8 preview) python
# 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.
Java Spring Security comparison: In Spring Boot you'd annotate a method with @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

app/templates/admin/login.html jinja2 + 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.

app/templates/admin/dashboard.html jinja2 + html
{% 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

Project structure — additions highlighted
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

terminal
(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_session cookie 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

Open DevTools → Application → Cookies → localhost. The 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?

If 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 on SessionMiddleware provides 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 .env is in your .gitignore.
.gitignore — verify these lines are present text
.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

Quick Reference — Chapter 7

app.add_middleware(SessionMiddleware, secret_key=...) Enable signed session cookies — must be before routes
request.session["key"] = value Write to the session dict (auto-saved to cookie)
request.session.get("admin_logged_in") Read from session — returns None if key absent
request.session.clear() Log out — clears all session data
pwd_context.hash("mypassword") Create bcrypt hash (store in .env, never plain text)
pwd_context.verify(plain, hashed) Returns True if plain matches the stored hash
_: None = Depends(require_admin) Protect any route — redirects to /admin/login if unauth
username: str = Form(...) Bind HTML form field to route parameter (needs python-multipart)