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:

DirectiveWidened by
img-srcui.map_tile_url
script-srcui.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()