Authentication and security
Argon2id, session and JWT, lockout with a growing delay, CSRF on every form, and a CSP that starts at default-src 'none'.
Available since v0.1.0
The user model
Any model. There is no base class to inherit from, which is what lets an existing schema get an admin with no migration.
fort.set_user_model(User, identity_field="email")
Five attributes are detected from the usual names — identity, password, active, staff, superuser. Name them differently and pass them explicitly:
fort.set_user_model(
User,
identity_field="login",
password_field="pwd",
active_field="enabled",
staff_field="can_admin",
superuser_field="is_root",
)
Detection reads the attribute names from the backend, not from the class.
SQLAlchemy puts a descriptor on the class for every column and Tortoise does not
— its fields live only in Model._meta — so hasattr(User, "email") is False
for a Tortoise model that plainly has an email.
Passwords
Argon2id, through pwdlib. A password column is detected from its type or its
name, so a plain String called hashed_password gets the right control without
you saying anything: a new password box plus a confirmation, hashed on save,
and left alone when both are blank.
FastFortSettings(
auth={"password_min_length": 10, "password_reject_common": True},
)
Sensitive fields are never echoed back into a form and are masked in audit records.
Lockout
auth={
"lockout_threshold": 5,
"lockout_seconds": 60,
"lockout_window_seconds": 900,
}
Five failures within fifteen minutes locks the identity and the address for a minute, and the delay grows with each further failure.
Sessions and tokens
Two lifetimes, because they are two different things:
auth={
"session_ttl": 1_209_600, # an admin browser session
"access_token_ttl": 900, # a JWT for an API client
"refresh_token_ttl": 1_209_600,
}
A person’s working day is not an API client’s refresh window.
auth={
"rotate_refresh_tokens": True,
"revoke_family_on_reuse": True,
"algorithm": "HS256", # or HS384/512, RS256, EdDSA
}
Each refresh issues a new token and retires the old one. Replaying a retired token means it was stolen, so the whole family is revoked — the session ends rather than continuing alongside the attacker’s.
RS256 and EdDSA are there for deployments that verify tokens in another
service, which then needs only the public key.
The gate
Every admin route is behind one router-level dependency, not a check inside each view. There is no view that can forget it.
public carries exactly three things: the static assets, the login page, and the
language switch.
A missing row is 404, never 403. Confirming that a record exists to somebody who may not be allowed to see it is a leak, so the two answers are made indistinguishable.
CSRF
security={
"csrf_enabled": True,
"csrf_header_name": "X-CSRF-Token",
"csrf_field_name": "_csrf",
}
On every form. Signed with the same secret_key, so rotating it invalidates
sessions, CSRF tokens and JWTs together.
Cookies
security={
"cookie_name": "fastfort_session",
"cookie_secure": True,
"cookie_httponly": True,
"cookie_samesite": "lax",
}
The defaults are the strict ones. Relaxing any of them takes an explicit change
that shows up in review — and check --deploy reports it anyway.
cookie_samesite="none" requires cookie_secure=True; the settings validator
refuses the combination outright rather than letting it ship.
The CSP
Starts at default-src 'none' with script-src 'self' and no nonce.
That single decision produces most of the front end’s shape:
- No inline
<script>, anywhere. - No inline event handlers.
- No CDN. Data reaches the browser through
data-attributes, which the script reads.
Exactly two directives can be widened by a project, each by exactly one origin:
| Directive | Widened by |
|---|---|
img-src | ui.map_tile_url |
script-src | ui.richtext_url |
Both are settings rather than template hooks precisely so that widening the policy is a change somebody reviews.
Alongside it: X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
and HSTS when security.hsts_seconds is set.
security={"security_headers": True, "hsts_seconds": 31_536_000}
Compression, and what is deliberately not compressed
Static assets are negotiated Brotli → gzip → identity, cached per encoding per process. HTML is deliberately excluded.
A page holds a CSRF token and request-chosen text, which is the BREACH pattern:
compressing them together lets an attacker who can influence the text learn the
token from the response size. Do not “fix” this by adding GZipMiddleware.
Uploaded files
Served through the admin, behind the same gate as every other view — not through a separate unauthenticated static mount. An uploaded file is a record like any other row, not a public asset.
media={"root": "media", "upload_limit": 10_000_000}
A request reads at most one byte past the limit before deciding the upload is over, so an oversized file is a validation error on the field rather than however many gigabytes it actually was, sitting fully in memory first.
Before you deploy
uv run fastfort check --app main:fort --deploy
Or make start-up refuse:
settings.require_production_ready()