FastHTML User Authentication: OAuth, Login & Admin Controls
Learn how to add FastHTML user authentication with GitHub OAuth, email login, sessions, CSRF protection, and admin role-based controls to your Python app.

FastHTML Tutorial Series
Part 6 of 6
In this FastHTML user authentication tutorial, we’ll add GitHub OAuth login, email/password registration, and role-based admin controls to the AI Title Generator from our series. This is Part 6. If you’ve been following along from FastHTML for Beginners through the multi-page project structure and the AI Title Generator we built earlier, you now have a working app that generates titles with an LLM and stores them in SQLite. Today we lock it down.
By the end you’ll have:
- GitHub OAuth login via the built-in
GitHubAppClient(no manualrequestscode) - Email/password registration and login with secure password hashing
- CSRF protection on every state-changing form
- Session hardening (
secret_key,max_age,same_site, HTTPS-only) - Per-request role-based admin controls using Beforeware
- User-specific and admin-wide history dashboards
Series: FastHTML Tutorial Series
This is Part 6. Earlier parts cover building a UI, multi-page structure, SQLite with Fastlite, complex AI tools, and PydanticAI integration. FastHTML is a Python web framework from AnswerDotAI that uses HTMX and Starlette under the hood.
Prerequisites
Before you start, make sure you have:
- Python >= 3.10
python-fasthtml>=0.14,<0.15pinned in yourrequirements.txt- A GitHub account with an OAuth App registered (Settings > Developer settings > OAuth Apps)
- The Title Generator from Part 5 running locally
- A
.envfile withSECRET_KEY,GITHUB_CLIENT_ID,GITHUB_CLIENT_SECRET,ADMIN_EMAIL, andADMIN_PASSWORD
Version Pinning
This tutorial targets python-fasthtml 0.14.x. Version 0.14.1 removed several imports from fasthtml.common (including uvicorn, Database, fastlite, and pico), and 0.12.0 switched fastlite to apsw. If you’re upgrading from an older version, pin python-fasthtml>=0.14,<0.15 in your requirements.txt and update your imports accordingly.
Overview
This FastHTML user authentication system has several cooperating layers. Here’s how the pieces fit together:
| Component | Files | Description | Key Features |
|---|---|---|---|
| Database Layer | db/database.py, db/user_dao.py, db/history_dao.py |
Data persistence and password security | SQLite with users + title_history tables, PBKDF2 600k iterations, foreign keys, auto-admin creation |
| Authentication Services | auth/auth_manager.py, auth/email_auth.py |
Login, session management, password validation | Email/password auth, session helpers, role checks |
| GitHub OAuth | main.py (inline OAuth subclass) |
Built-in FastHTML GitHub OAuth flow | GitHubAppClient + OAuth subclass, automatic redirect handling |
| Beforeware | main.py |
Per-request auth and admin checks | Replaces per-route require_auth(), injects auth param, live is_admin lookup |
| CSRF | main.py, form templates |
Cross-site request forgery protection | Session-stored token, Hidden field, hmac.compare_digest validation |
| UI Components | components/header.py, components/page_layout.py |
Auth-aware navigation and layout | Dynamic nav based on auth status, admin links, CSRF tokens in forms |
| Public Pages | pages/home.py, pages/login.py, pages/register.py |
Accessible without login | Landing page, login form with GitHub + email, registration with validation |
| Protected Pages | pages/title_generator.py, pages/history.py |
Requires authentication | Title generation form, user-specific history dashboard |
| Admin Pages | pages/admin.py |
Requires admin role | User management, role assignment, global history view |
Authentication flow explanation
The FastHTML GitHub OAuth flow uses the built-in GitHubAppClient and OAuth subclass rather than manual requests calls. Here are the four flows:
1. Registration flow:
- User visits
/registerand submits the form email_register()handler validates input (format, password length, unique email)UserDAO.create_user()hashes the password with PBKDF2 (600k iterations) and stores it- On success, redirect to
/loginwith a success message
2. Email login flow:
- User submits email + password at
/login email_login()handler callsEmailAuth.authenticate()- Password is compared using
hmac.compare_digest()(constant-time) AuthManager.login_user()writesuser_idand a CSRF token to the session- Redirect to
/(Beforeware now recognizes the session)
3. GitHub OAuth login flow:
- User clicks “Sign in with GitHub” which links to
oauth.login_link(req) - GitHub redirects back to
/auth/github/callbackwith an authorization code - The
OAuthsubclass exchanges the code for user info viaclient.retr_info() get_auth()finds or creates the user in SQLite, sets the session- Redirect to
/automatically
4. Authorization check (Beforeware):
- Every request passes through
Beforeware(before, skip=[...]) before()checkssession.get('user_id'); if missing, redirects to/login- For admin routes, the handler does a per-request
is_adminDB lookup (not session-cached) so demotions take effect immediately - Public routes (
/,/login,/register, static files) are skipped
# Simplified Beforeware flow
login_redir = RedirectResponse('/login', status_code=303)
def before(req, sess):
auth = req.scope['auth'] = sess.get('user_id', None)
if not auth:
return login_redir
bware = Beforeware(before, skip=[r'/favicon\.ico', r'/static/.*', r'.*\.css',
'/', '/login', '/register', '/auth/github/callback'])
Database schema overview
The database layer uses SQLite with two tables. If you’ve been following the series, you already added a SQLite database with Fastlite. We’re extending that schema here.
Users table
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE,
email TEXT UNIQUE,
password_hash TEXT,
salt TEXT,
github_id TEXT UNIQUE,
is_admin BOOLEAN DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP
)
The is_admin flag controls role-based access. Rather than caching it in the session, we look it up from the database per-request via Beforeware. This means if you demote an admin in the database, they lose access immediately (no re-login required).
The password_hash and salt columns store FastHTML password hashing credentials using PBKDF2-HMAC-SHA256 with 600,000 iterations and a per-user random salt (secrets.token_hex(16)). Password comparison uses hmac.compare_digest() for constant-time comparison to prevent timing attacks.
FastHTML Password Hashing
OWASP recommends PBKDF2-HMAC-SHA256 with at least 600,000 iterations (up from the 100k many older tutorials use). Argon2id is the top recommendation for new projects. See the OWASP Password Storage Cheat Sheet. If you want Argon2id, use argon2-cffi or passlib instead of the stdlib hashlib approach shown here.
Title history table
CREATE TABLE IF NOT EXISTS title_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
topic TEXT NOT NULL,
platform TEXT NOT NULL,
style TEXT NOT NULL,
number_of_titles INTEGER NOT NULL,
titles TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)
The user_id foreign key associates each generation with a user. Regular users see only their own history; admins see everything. This is the same per-user data isolation pattern FastHTML’s docs recommend (todos.xtra(name=auth)).
Project structure updates
We’re cleaning up the file tree. The old auth/github_auth.py (manual OAuth with requests) is gone, replaced by the built-in GitHubAppClient in main.py. If you’re following the multi-page project structure from Part 3, the layout stays familiar:
your-app/
├── auth/
│ ├── auth_manager.py # Session helpers, login_user(), is_admin()
│ └── email_auth.py # Email/password validation and authentication
├── components/
│ ├── header.py # Auth-aware navigation
│ ├── footer.py # Footer component
│ └── page_layout.py # Page wrapper with header/footer
├── db/
│ ├── database.py # Database class, schema init, password hashing
│ ├── user_dao.py # User CRUD operations
│ └── history_dao.py # History CRUD operations
├── pages/
│ ├── home.py # Landing page
│ ├── login.py # Login form (email + GitHub OAuth)
│ ├── register.py # Registration form
│ ├── title_generator.py # Protected: title generation form
│ ├── history.py # Protected: user history dashboard
│ └── admin.py # Admin-only: user mgmt, global history
├── tools/
│ └── title_generator.py # LLM title generation logic
├── main.py # Routes, OAuth subclass, Beforeware, app init
├── config.py # Environment config
├── .env # Secrets (not committed)
└── requirements.txt # python-fasthtml>=0.14,<0.15, python-dotenv, etc.
Step-by-step implementation
Step 1: updating the database structure
The database schema is the same as before, but we’re bumping the PBKDF2 iteration count from 100,000 to 600,000 and switching to hmac.compare_digest() for password verification.
File: db/database.py
import os
import hashlib
import hmac
import secrets
from contextlib import contextmanager
import sqlite3
import config
class Database:
def __init__(self, db_path=None):
self.db_path = db_path or config.DB_PATH
if not self.db_path:
self.db_path = "tools.db"
self._initialize_db()
def _initialize_db(self):
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE,
email TEXT UNIQUE,
password_hash TEXT,
salt TEXT,
github_id TEXT UNIQUE,
is_admin BOOLEAN DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS title_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
topic TEXT NOT NULL,
platform TEXT NOT NULL,
style TEXT NOT NULL,
number_of_titles INTEGER NOT NULL,
titles TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)
''')
# Auto-create admin if configured
cursor.execute("SELECT COUNT(*) FROM users WHERE is_admin = 1")
if cursor.fetchone()[0] == 0 and config.ADMIN_EMAIL and config.ADMIN_PASSWORD:
salt = secrets.token_hex(16)
password_hash = self._hash_password(config.ADMIN_PASSWORD, salt)
cursor.execute('''
INSERT INTO users (username, email, password_hash, salt, is_admin)
VALUES (?, ?, ?, ?, 1)
''', ('admin', config.ADMIN_EMAIL, password_hash, salt))
conn.commit()
@staticmethod
def _hash_password(password, salt):
"""PBKDF2-HMAC-SHA256 with 600,000 iterations (OWASP minimum)."""
return hashlib.pbkdf2_hmac(
'sha256',
password.encode('utf-8'),
salt.encode('utf-8'),
600_000 # was 100_000 in the original article
).hex()
@contextmanager
def get_connection(self):
db_dir = os.path.dirname(self.db_path)
if db_dir:
os.makedirs(db_dir, exist_ok=True)
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
db = Database()
File: db/user_dao.py (key changes are the constant-time password comparison):
from typing import Optional, Dict, Any, List
import hmac
import secrets
from datetime import datetime
from .database import db
class UserDAO:
@staticmethod
def create_user(username: str, email: str, password: Optional[str] = None,
github_id: Optional[str] = None) -> int:
try:
with db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT id FROM users WHERE email = ? OR username = ? "
"OR (github_id = ? AND github_id IS NOT NULL)",
(email, username, github_id)
)
if cursor.fetchone():
return 0
password_hash = None
salt = None
if password:
salt = secrets.token_hex(16)
password_hash = db._hash_password(password, salt)
cursor.execute('''
INSERT INTO users (username, email, password_hash, salt, github_id, is_admin)
VALUES (?, ?, ?, ?, ?, 0)
''', (username, email, password_hash, salt, github_id))
conn.commit()
return cursor.lastrowid
except Exception as e:
print(f"Error creating user: {e}")
return 0
@staticmethod
def authenticate_email(email: str, password: str) -> Optional[Dict[str, Any]]:
with db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM users WHERE email = ?", (email,)
)
user = cursor.fetchone()
if not user:
return None
if not user['password_hash'] or not user['salt']:
return None
# Constant-time comparison (OWASP recommendation)
password_hash = db._hash_password(password, user['salt'])
if not hmac.compare_digest(password_hash, user['password_hash']):
return None
# Update last_login
cursor.execute(
"UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?",
(user['id'],)
)
conn.commit()
return dict(user)
@staticmethod
def find_or_create_github_user(github_id: str, username: str,
email: str) -> Optional[Dict[str, Any]]:
try:
with db.get_connection() as conn:
cursor = conn.cursor()
# Try to find by github_id first
cursor.execute("SELECT * FROM users WHERE github_id = ?", (github_id,))
user = cursor.fetchone()
if user:
cursor.execute(
"UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?",
(user['id'],)
)
conn.commit()
return dict(user)
# Create new GitHub user
cursor.execute('''
INSERT INTO users (username, email, github_id, is_admin)
VALUES (?, ?, ?, 0)
''', (username, email, github_id))
conn.commit()
cursor.execute("SELECT * FROM users WHERE id = ?", (cursor.lastrowid,))
return dict(cursor.fetchone())
except Exception as e:
print(f"Error with GitHub user: {e}")
return None
@staticmethod
def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
with db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))
user = cursor.fetchone()
return dict(user) if user else None
@staticmethod
def is_user_admin(user_id: int) -> bool:
"""Check admin status from DB (not session) for live demotion."""
with db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT is_admin FROM users WHERE id = ?", (user_id,))
user = cursor.fetchone()
return bool(user and user['is_admin'])
@staticmethod
def get_all_users() -> List[Dict[str, Any]]:
with db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT id, username, email, github_id, is_admin, "
"created_at, last_login FROM users ORDER BY created_at DESC")
return [dict(row) for row in cursor.fetchall()]
@staticmethod
def set_admin(user_id: int, is_admin: bool) -> bool:
with db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("UPDATE users SET is_admin = ? WHERE id = ?",
(int(is_admin), user_id))
conn.commit()
return cursor.rowcount > 0
@staticmethod
def delete_user(user_id: int) -> bool:
try:
with db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM title_history WHERE user_id = ?", (user_id,))
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
print(f"Error deleting user: {e}")
return False
The HistoryDAO stays largely the same as the original. It filters by user_id for regular users and returns all records for admins.
Breaking Change: fastlite 0.12.0+
If you’re upgrading from an older version, fastlite now uses apsw instead of stdlib sqlite3. If your database was created with the old driver, you may need to re-create it. The Database class shown above uses stdlib sqlite3 directly, which still works fine.
Verify: Run python -c "from db.database import Database; Database()" to confirm the schema initializes without errors. You should see a tools.db file created.
Step 2: configuration and session security
File: config.py
import os
from dotenv import load_dotenv
load_dotenv()
# Database
DB_PATH = os.getenv("DB_PATH", "tools.db")
# Security -- no placeholder default! Fail fast if unset.
SECRET_KEY = os.getenv("SECRET_KEY")
if not SECRET_KEY:
raise ValueError("SECRET_KEY environment variable is required. "
"Generate one with: python -c 'import secrets; print(secrets.token_hex(32))'")
# Session
SESSION_EXPIRY = int(os.getenv("SESSION_EXPIRY", 604800)) # 7 days in seconds
# GitHub OAuth
GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID", "")
GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET", "")
# Admin bootstrap
ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "")
# LLM (inherited from Part 5)
DEFAULT_MODEL = os.getenv("DEFAULT_MODEL", "openai/gpt-4o-mini")
# App
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
Don't Use Placeholder Secrets
The old code had "your-secret-key-change-in-production" as a default for SECRET_KEY. That’s a footgun. Anyone who forgets to set the env var runs with a known key. The config above raises ValueError immediately if SECRET_KEY is unset. Generate a key with: python -c "import secrets; print(secrets.token_hex(32))"
App initialization with Beforeware
File: main.py (app init and Beforeware, which replaces the old per-route require_auth() pattern):
from fasthtml.common import *
from fasthtml.oauth import GitHubAppClient, OAuth, redir_url
import secrets
import hmac
import config
from auth.auth_manager import AuthManager
from auth.email_auth import EmailAuth
from db.user_dao import UserDAO
from db.history_dao import HistoryDAO
login_redir = RedirectResponse('/login', status_code=303)
def before(req, sess):
"""Beforeware: check session auth, redirect to login if missing."""
auth = req.scope['auth'] = sess.get('user_id', None)
if not auth:
return login_redir
bware = Beforeware(before, skip=[
r'/favicon\.ico', r'/static/.*', r'.*\.css',
'/', '/login', '/register',
'/auth/github/callback',
'/error',
])
app = FastHTML(
secret_key=config.SECRET_KEY,
before=bware,
session_cookie="session",
max_age=config.SESSION_EXPIRY,
sess_path="/",
same_site="lax",
sess_https_only=not config.DEBUG,
)
# Route helper: get user_id from req.scope (set by Beforeware)
def get_auth(req): return req.scope['auth']
./sesskey Behavior
If you don’t pass secret_key to FastHTML(), it auto-generates a signing key and stores it in ./sesskey. This means sessions survive restarts, but you should add sesskey to .gitignore if you rely on this. For production, always set SECRET_KEY explicitly.
The Beforeware pattern replaces the old per-route require_auth(session) calls. Handlers that need the user ID declare an auth parameter. FastHTML injects it from req.scope['auth']. The skip list defines public routes that don’t need authentication.
Step 3: GitHub OAuth with GitHubAppClient
This is the biggest code change. The old auth/github_auth.py (about 100 lines of manual requests-based OAuth with unencoded query strings) gets replaced by ~30 lines using FastHTML’s built-in OAuth.
GitHub OAuth App setup
- Go to GitHub > Settings > Developer settings > OAuth Apps > New OAuth App
- Set the Authorization callback URL to your full URL:
http://localhost:5001/auth/github/callback - Copy the Client ID and Client Secret to your
.envfile
The callback URL must be an absolute URL (not a relative path like /auth/github/callback). FastHTML’s redir_url() builds it automatically from the request host.
The OAuth subclass
# In main.py, after app initialization
client = GitHubAppClient(
config.GITHUB_CLIENT_ID,
config.GITHUB_CLIENT_SECRET,
scope="user:email"
)
class Auth(OAuth):
def get_auth(self, info, ident, session, state):
"""
Called after GitHub redirects back.
Args:
info: dict with 'login', 'email' (may be None), etc.
ident: the GitHub user ID (from client.id_key == 'id')
session: the Starlette session
state: the CSRF state parameter
"""
# GitHub may return email: null for non-public emails
email = info.get('email') or f"{info['login']}-{ident}@github.user"
user = UserDAO.find_or_create_github_user(
str(ident), info['login'], email
)
if not user:
return None # redirects back to /login
AuthManager.login_user(session, user)
# Generate CSRF token on login
session['csrf'] = secrets.token_hex(32)
return RedirectResponse('/', status_code=303)
oauth = Auth(app, client, redir_path='/auth/github/callback', login_path='/login')
Then in your login page template:
# pages/login.py
from fasthtml.common import *
def login_page(error=None, success=None, session=None):
return Div(
# Error/success messages...
H2("Sign In", cls="text-2xl font-bold mb-6"),
# GitHub OAuth button
A("Sign in with GitHub",
href="/login", # OAuth's login_path handles the redirect
cls="block w-full text-center bg-gray-800 text-white py-2 px-4 rounded mb-4 hover:bg-gray-700"),
Div("or", cls="text-center text-gray-500 my-4"),
# Email/password form
Form(
# CSRF token in every state-changing form
Input(type="hidden", id="csrf", value=session.get('csrf', '')),
Input(type="email", name="email", placeholder="Email", required=True,
cls="w-full p-2 border rounded mb-3"),
Input(type="password", name="password", placeholder="Password",
required=True, cls="w-full p-2 border rounded mb-3"),
Button("Sign In with Email", type="submit",
cls="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700"),
method="post", action="/auth/email/login"
),
# Register link
P(A("Create an account", href="/register", cls="text-blue-600"),
cls="mt-4 text-center"),
cls="max-w-md mx-auto mt-10 p-6 bg-white rounded shadow"
)
GitHub Email Edge Case
When scope="user:email", GitHub’s /user endpoint returns email: null for users who haven’t set a public email. The fallback ({username}-{id}@github.user) creates a placeholder email. For production, you’d call https://api.github.com/user/emails with the access token to get the primary email. The GitHubAppClient handles the token exchange for you.
Verify: Visit /login in your browser. The “Sign in with GitHub” link should redirect to https://github.com/login/oauth/authorize?.... After authorizing, you should be redirected back and logged in.
Step 4: email and password authentication
The EmailAuth service handles registration validation and login. With Beforeware in place, the handlers just write to session['user_id'] (no manual auth checks needed).
File: auth/email_auth.py
import re
from typing import Optional, Dict, Any, Tuple
class EmailAuth:
@staticmethod
def validate_registration(username: str, email: str,
password: str) -> Tuple[bool, str]:
"""Validate registration input. Returns (is_valid, error_message)."""
if not username or len(username) < 3:
return False, "Username must be at least 3 characters"
if not email or not re.match(r'^[\w\.-]+@[\w\.-]+\.\w+$', email):
return False, "Invalid email address"
if not password or len(password) < 8:
return False, "Password must be at least 8 characters"
if not re.search(r'[A-Z]', password) or not re.search(r'[0-9]', password):
return False, "Password must contain at least one uppercase letter and one number"
return True, ""
@staticmethod
def authenticate(email: str, password: str) -> Optional[Dict[str, Any]]:
"""Authenticate user with email/password."""
from db.user_dao import UserDAO
return UserDAO.authenticate_email(email, password)
Route handlers in main.py:
@app.post('/auth/email/register')
def email_register(req, username: str, email: str, password: str,
confirm_password: str, csrf: str = ""):
session = req.session
# CSRF validation
if not hmac.compare_digest(session.get('csrf', ''), csrf):
return RedirectResponse('/register', status_code=303)
# Validate input
valid, error = EmailAuth.validate_registration(username, email, password)
if not valid:
return register_page(error=error, session=session)
if password != confirm_password:
return register_page(error="Passwords do not match", session=session)
# Create user
from db.user_dao import UserDAO
user_id = UserDAO.create_user(username=username, email=email, password=password)
if not user_id:
return register_page(error="Email or username already exists", session=session)
return RedirectResponse('/login?registered=1', status_code=303)
@app.post('/auth/email/login')
def email_login(req, email: str, password: str, csrf: str = ""):
session = req.session
# CSRF validation
if not hmac.compare_digest(session.get('csrf', ''), csrf):
return login_page(error="Invalid request", session=session)
user = EmailAuth.authenticate(email, password)
if not user:
return login_page(error="Invalid email or password", session=session)
AuthManager.login_user(session, user)
session['csrf'] = secrets.token_hex(32)
return RedirectResponse('/', status_code=303)
Verify: Register a new user at /register, then log in at /login. Check that you’re redirected to / and the nav shows your username.
Step 5: CSRF protection
FastHTML has no built-in CSRF protection. We’re adding a session-stored random token pattern that protects every state-changing form.
The approach:
- On login (email or GitHub), generate
session['csrf'] = secrets.token_hex(32) - Include
Input(type="hidden", id="csrf", value=session.get('csrf', ''))in every form that POSTs - In every POST handler, validate with
hmac.compare_digest(session.get('csrf', ''), csrf) - If the token doesn’t match, reject the request
# Helper to inject CSRF hidden field into forms
def csrf_field(session):
return Input(type="hidden", id="csrf", name="csrf",
value=session.get('csrf', ''))
# In any POST form:
Form(
csrf_field(session),
Input(type="text", name="topic", ...),
Button("Generate Titles", type="submit"),
method="post", action="/generate"
)
# In any POST handler:
@app.post('/generate')
def generate_titles(req, topic: str, csrf: str = "", ...):
session = req.session
if not hmac.compare_digest(session.get('csrf', ''), csrf):
return Div("Invalid request", cls="text-red-600")
# ... rest of handler
FastHTML Has No Built-in CSRF Protection
This is a DIY pattern based on Marius Vach’s guide. Every state-changing form (register, login, make-admin, delete-user, delete-history) needs the hidden csrf field. Don’t skip this. Admin actions are high-value targets.
Verify: Open your browser’s dev tools on any form, inspect the HTML, and confirm the hidden csrf field is present with a random hex value. Submit a form without it (or with a tampered value) and confirm it’s rejected.
Step 6: role-based access control and admin pages
The admin controls use the is_admin database flag. The key design decision: is_admin is looked up from the database per-request (via UserDAO.is_user_admin()), not cached in the session. This means demoting an admin in the database takes effect immediately.
# In main.py -- admin route helper
def require_admin(req):
"""Check admin status from DB. Returns user_id or raises."""
user_id = get_auth(req)
if not UserDAO.is_user_admin(user_id):
raise HTTPException(403, "Admin access required")
return user_id
# Admin dashboard
@app.get('/admin')
def admin_dashboard(req):
user_id = require_admin(req)
users = UserDAO.get_all_users()
return page_layout("Admin Dashboard",
admin_dashboard_content(users),
current_page="/admin", session=req.session)
@app.post('/admin/make-admin')
def make_admin(req, user_id: int, csrf: str = ""):
session = req.session
if not hmac.compare_digest(session.get('csrf', ''), csrf):
raise HTTPException(401, "Invalid CSRF token")
admin_id = require_admin(req)
UserDAO.set_admin(user_id, True)
return RedirectResponse('/admin', status_code=303)
@app.post('/admin/delete-user')
def delete_user(req, user_id: int, csrf: str = ""):
session = req.session
if not hmac.compare_digest(session.get('csrf', ''), csrf):
raise HTTPException(401, "Invalid CSRF token")
admin_id = require_admin(req)
if user_id == admin_id:
return RedirectResponse('/admin?error=cannot_delete_self', status_code=303)
UserDAO.delete_user(user_id)
return RedirectResponse('/admin', status_code=303)
The admin pages include:
- Admin dashboard (
/admin): overview with user count, recent registrations, generation stats - Admin users (
/admin/users): list all users with make-admin/delete actions - Admin history (
/admin/history): view all users’ generation history
Step 7: updating UI components
The header and layout components now use the auth parameter injected by Beforeware.
File: components/header.py
from fasthtml.common import *
from db.user_dao import UserDAO
def header(current_page="/", auth=None):
"""Auth-aware navigation header."""
nav_items = [A("Home", href="/", cls="hover:text-blue-600")]
if auth:
nav_items.extend([
A("Title Generator", href="/title-generator",
cls="hover:text-blue-600"),
A("My History", href="/history",
cls="hover:text-blue-600"),
])
# Admin link only for admins (DB check, not session)
if UserDAO.is_user_admin(auth):
nav_items.append(
A("Admin", href="/admin", cls="hover:text-red-600 font-semibold")
)
nav_items.append(
A("Logout", href="/logout", cls="hover:text-red-600")
)
else:
nav_items.extend([
A("Login", href="/login", cls="hover:text-blue-600"),
A("Register", href="/register", cls="hover:text-blue-600"),
])
return Nav(
Div(
A("AI Title Generator", href="/", cls="text-xl font-bold"),
Div(*nav_items, cls="flex gap-4"),
cls="container mx-auto px-4 py-3 flex justify-between items-center"
),
cls="bg-white shadow"
)
File: components/page_layout.py (note the auth parameter):
from fasthtml.common import *
from .header import header
from .footer import footer
def page_layout(title, content, current_page="/", auth=None):
return Html(
Head(
Title(title),
Meta(charset="UTF-8"),
Meta(name="viewport", content="width=device-width, initial-scale=1.0"),
Script(src="https://cdn.tailwindcss.com"),
),
Body(
Div(
header(current_page, auth),
Main(
Div(content, cls="container mx-auto px-4 py-8"),
cls="flex-grow"
),
footer(),
cls="flex flex-col min-h-screen"
)
)
)
Step 8: protected routes – title generator and user history
With Beforeware handling auth, these routes are automatically protected. No manual require_auth() call needed.
# Title generator -- protected by Beforeware
@app.get('/title-generator')
def title_generator_page(req):
auth = get_auth(req)
return page_layout("Title Generator",
title_generator_form(),
current_page="/title-generator", auth=auth)
@app.post('/generate')
def generate_titles(req, topic: str, platform: str, style: str,
number: int, csrf: str = ""):
session = req.session
auth = get_auth(req)
# CSRF check
if not hmac.compare_digest(session.get('csrf', ''), csrf):
return title_generator_form(error="Invalid request")
# Generate titles (LLM call from Part 5)
from tools.title_generator import generate
titles = generate(topic, platform, style, number)
# Save to history with user_id
HistoryDAO.save_generation(auth, topic, platform, style, number, titles)
return title_generator_form(titles=titles, session=session, auth=auth)
# User history -- shows only the logged-in user's records
@app.get('/history')
def history_page(req):
auth = get_auth(req)
records = HistoryDAO.get_user_history(auth)
return page_layout("My History",
user_history_content(records),
current_page="/history", auth=auth)
# Admin history -- shows all users (admin check via DB)
@app.get('/admin/history')
def admin_history(req):
auth = require_admin(req)
records = HistoryDAO.get_all_history()
return page_layout("All History",
admin_history_content(records),
current_page="/admin/history", auth=auth)
Step 9: environment variables and running the app
Create your .env file:
# .env
SECRET_KEY=your-random-64-char-hex-string
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=YourStr0ngPassword
DB_PATH=tools.db
DEBUG=true
SESSION_EXPIRY=604800
DEFAULT_MODEL=openai/gpt-4o-mini
Install dependencies and run:
pip install python-fasthtml>=0.14,<0.15 python-dotenv
python main.py
Open http://localhost:5001 in your browser. You should see:
- The landing page with login/register options
- After logging in (email or GitHub), access to the title generator and your history
- Admin dashboard if you logged in with the admin credentials
GitHub Scope
The OAuth client uses scope="user:email", which is sufficient to retrieve the user’s profile and email. The older read:user user:email scope combination is no longer required.
Verify and troubleshoot
Version and import issues
python-fasthtml 0.14.1 removed several re-exports from fasthtml.common:
| What was removed | Where to import it now |
|---|---|
uvicorn |
import uvicorn directly |
Database (apswutils) |
from fastlite import Database |
fastlite components |
from fastlite import * |
pico components |
from fasthtml.pico import picolink |
If you get ImportError after upgrading, check these imports first. This is the #1 thing that breaks when moving from 0.6.x to 0.14.x.
Testing auth flows
FastHTML 0.14.1 added FastHTMLTestClient with session decoding:
from fasthtml.test import FastHTMLTestClient
client = FastHTMLTestClient(app)
# Test registration
resp = client.post('/auth/email/register', data={
'username': 'testuser',
'email': 'test@example.com',
'password': 'TestPass123',
'confirm_password': 'TestPass123',
'csrf': '' # bypass CSRF in tests or set session first
})
assert resp.status_code == 303
# Test login
resp = client.post('/auth/email/login', data={
'email': 'test@example.com',
'password': 'TestPass123',
'csrf': ''
})
assert resp.status_code == 303
assert 'session' in resp.cookies
Common failure modes
How do I fix ./sesskey committed to git?
If ./sesskey is committed to git, anyone who clones the repo gets your session signing key. Add sesskey to .gitignore immediately and rotate the key by deleting the file and restarting the app (or set SECRET_KEY explicitly).
GitHub OAuth callback error
The callback URL must be absolute: http://localhost:5001/auth/github/callback (not /auth/github/callback). FastHTML’s redir_url() builds this from the request host, so it works in dev and production. Make sure your GitHub OAuth App’s “Authorization callback URL” matches.
GitHub username collision on signup
If a GitHub user’s username collides with an existing email/password user’s username (which has a UNIQUE constraint), the find_or_create_github_user() insert will fail. The broad except catches this and returns None, sending the user back to /login. For production, handle this with a generated username fallback.
Demoted admin still has access
If you’re caching is_admin in the session, demotions don’t take effect until re-login. The code in this tutorial does a per-request UserDAO.is_user_admin() lookup from the database, so demotions are immediate. If you’re seeing stale admin access, check that your admin routes call require_admin() (DB lookup) instead of reading from the session.
App won't start: SECRET_KEY not set
The config raises ValueError if SECRET_KEY is missing. Generate one: python -c "import secrets; print(secrets.token_hex(32))" and add it to your .env file.
Password comparison timing attack
The old code used != for password comparison, which is not constant-time. The updated code uses hmac.compare_digest() which takes the same time regardless of where strings differ. This prevents timing-based password guessing.
Brute-force protection
There’s no rate limiting on /auth/email/login or the OAuth callback. For production, add slowapi with @limiter.limit("30/day") on login endpoints, or put fail2ban in front of your reverse proxy. The fasthtml-admin package (AndreasThinks, GitHub) uses this pattern out of the box.
Production notes
SQLite in production
A single-instance VPS is fine for SQLite. Enable WAL mode for better concurrency:
conn.execute("PRAGMA journal_mode=WAL")
The signed-cookie session means multiple uvicorn workers can share auth state – there’s no server-side session store to synchronize. If you’re scaling beyond one box, consider PostgreSQL via fastlite.
For deployment, you can run your FastHTML app in Docker or deploy the app with Dokploy. A Hetzner VPS at ~5 EUR/month handles this workload without issues. Hostinger VPS is another budget option with KVM virtualization.
Third-Party Auth Packages
If you need email verification, password reset, or a more complete auth system, check out fasthtml-admin (AndreasThinks, 17 GitHub stars, MIT license) which implements registration with email confirmation, Beforeware-based auth, OAuth, and DB backup/restore. fasthtml-auth on PyPI advertises RBAC, session management, and OAuth providers. Both are young packages – test before relying on them in production. To harden your server beyond application-level auth, see the BSI security checklist.
Conclusion
We’ve built a complete FastHTML user authentication system for the AI Title Generator: GitHub OAuth via the built-in GitHubAppClient, email/password registration with secure password hashing (PBKDF2 600k iterations), CSRF protection on every form, session hardening with proper secret_key management, and role-based admin controls with per-request database lookups. The Beforeware pattern keeps auth logic in one place instead of scattered across every route handler.
From here, the natural next steps are email verification, password reset, and team-based access – but those are a separate tutorial. The fasthtml-admin package covers some of that ground if you need it now.


