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.

The admin itself uses the session cookie and nothing else, and that is deliberate. A token in localStorage is readable by any script that gets onto the page; a token in a cookie is a session cookie with extra steps. JWTs are for the API a project builds beside its admin — the mobile client, the integration, the service account — where there is no cookie jar and no CSRF story.

The token API

Four routes, mounted at auth_url when you ask for them:

FastFortSettings(
    secret_key=...,
    auth={"api_enabled": True},
)
POST /auth/token     identity + password  → access + refresh
POST /auth/refresh   refresh              → access + refresh
POST /auth/logout    refresh              → 204
GET  /auth/me        Bearer access        → who that is

Off by default. Every other route FastFort adds is behind the admin’s gate; these four are public by necessity, and adding public endpoints to somebody else’s application without being asked is not a library’s decision to make.

The request and response shapes are OAuth 2’s, so every generated client and every curl example from any other API works without translation:

curl -X POST http://127.0.0.1:8000/auth/token \
  -d identity=admin@example.com -d password=...
{
  "access_token": "eyJhbGciOi...",
  "refresh_token": "eyJhbGciOi...",
  "token_type": "Bearer",
  "expires_in": 900
}

POST /auth/token authenticates through exactly the call the admin’s own sign-in form makes, so the lockout counters, the timing-safe comparison and the rehash-on-login all apply — with one difference: it does not require a staff account. An API’s users are customers, and refusing to authenticate one for not being an administrator would refuse the ordinary case. Set auth={"api_requires_staff": True} for an API that serves only the same people the admin does. is_active is required either way.

Protecting your own routes

The part worth having even if the four routes above are not wanted:

from typing import Annotated, Any
from fastapi import Depends
from fastfort.auth import bearer_user

current_user = bearer_user(fort)

@app.get("/orders")
async def orders(user: Annotated[Any, Depends(current_user)]) -> list[Order]:
    return await orders_for(user)

It hands the route your own user row, not a claims dict — a route that has to look the row up itself has been handed half an answer. On the way there it verifies the signature, checks the token is an access token rather than a refresh one, and rejects a user who has since been deactivated. That last check is the difference between a token expiring in fifteen minutes and a closed account continuing to work for fifteen minutes.

Personal access tokens

The JWT flow above is right for a client with a person behind it. It is the wrong shape for a cron job, a deployment script or a partner integration: those have no password to type, no way to refresh at three in the morning, and nobody awake to notice a failed refresh. What they need is one long-lived string, revocable on its own.

The table is yours, the way every FastFort feature that needs one works:

from fastfort.orm.sqlalchemy import ApiTokenMixin

class ApiToken(ApiTokenMixin, Base):
    __tablename__ = "admin_api_token"

fort.enable_api_tokens(ApiToken)

The columns are checked there and then, so a mistyped schema is a start-up error rather than a 500 on the first authenticated request.

Minting one

From the admin, by registering it:

from fastfort.admin import ApiTokenAdmin

fort.register(ApiToken, ApiTokenAdmin)

The Add button becomes the minting page — a secret has to be generated rather than typed. The page shows the token exactly once and cannot show it again: it renders rather than redirects, so the reveal has no URL to return to, and refreshing gives you the minting form back.

Or in code, which is what a seeding script wants:

issued = await fort.api_tokens.issue(
    user=user,
    name="nightly-export",
    scopes="orders:read",          # yours to define; FastFort takes no view
    expires_at=None,               # None means it does not expire
)
print(issued.secret)               # the only time it is readable

Using one

from fastfort.auth import token_user

machine = token_user(fort)

@app.get("/orders")
async def orders(user: Annotated[Any, Depends(machine)]) -> list[Order]:
    return await orders_for(user)

A separate dependency from bearer_user rather than one that accepts either. A JWT is a session with a person behind it and a token is a standing grant to a machine; a route that accepts both should say so by depending on both, rather than inheriting it from a helper that quietly widened.

What is stored

Not the secret. The row keeps a SHA-256 digest and an eight-character prefix, and the prefix exists so you can match a row in the admin against the string in your configuration file without the row being enough to authenticate with.

The digest is SHA-256 rather than Argon2, and that is deliberate. A work factor exists to make guessing expensive, and guessing is only a threat against a guessable secret — a human password from a small, skewed distribution. These are 32 bytes from secrets.token_urlsafe: 256 bits of uniform entropy, with no dictionary to try. There is nothing for a work factor to slow down, and putting one here would cost every authenticated request the ~100ms Argon2 is tuned to take.

Refusals

Unknown, revoked, expired, or owned by an account that has been deleted or deactivated — all four are the same answer. Telling them apart would tell whoever is holding the string which of the four it is.

Unlike a sign-in record, a token must not outlive its account, so resolution looks the owner up on every request. Revoking keeps the row, because the row still answers what this was and when it stopped; deleting says it should never have existed.

Reacting to what the admin does

fort.hooks is how a project reacts to a write without subclassing anything:

from fastfort.core.hooks import Hook

@fort.hooks.on(Hook.AFTER_CREATE)
async def announce(*, request, model_key, obj, **_):
    await queue.publish("created", model_key, obj.id)

Every write path the admin owns emits: the form’s create and change, the single and bulk delete, restore, in-place cell editing, bulk edit, and import.

When they fire is the part to write against.

BEFORE_* runs inside the transaction, immediately before the write, so a listener that raises aborts it — that is what makes a veto possible:

@fort.hooks.on(Hook.BEFORE_DELETE)
async def protect(*, obj, **_):
    if obj.is_locked:
        raise ValidationError("Locked records cannot be deleted.")

AFTER_* runs once the transaction has committed, so AFTER_CREATE means the row is durable rather than merely flushed. A listener that sends mail or pushes to a queue never fires for a change that then rolled back — which also means a listener that raises there cannot undo the write, and surfaces as an error on a request that did change the database. Listener errors propagate deliberately: a hook that fails silently is worse than no hook, because the caller believes the work happened.

A bulk path writes many rows in one transaction, so every BEFORE_* in a batch precedes every AFTER_* in it rather than interleaving. The alternative would announce one row durable while a later row in the same transaction could still take it back.

Two details worth knowing: a soft delete emits the delete pair, not the update pair — it is a write to one column in mechanism and a deletion in intent. And BEFORE_CREATE carries the cleaned values rather than an instance, because there is not one yet.

Rotation, and what a replay means

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 one presented, so a stolen refresh token is useful exactly until the real client refreshes — and then one of the two presents a token that has already been spent.

That replay is the detection. It cannot be told apart from an attacker using a stolen token alongside the real client, so the safe reading is that it was one, and the whole family is revoked: every token descended from the same sign-in. The legitimate client is signed out and has to authenticate again. That is a real cost, and a much smaller one than an attacker holding a live session.

Which is why the refresh side needs a store and the access side does not. An access token is verified by its signature alone — no round trip, no shared state between workers — and is short-lived precisely so that being unable to revoke it does not matter for long.

RS256 and EdDSA are there for deployments that verify tokens in another service, which then needs only the public key:

fort.configure_tokens(
    signing_key=PRIVATE_KEY_PEM,
    verifying_key=PUBLIC_KEY_PEM,
)

The secret key signs cookies and is not an asymmetric key, so asking for RS256 without a pair raises at construction rather than at the first token.

Rate limiting

On by default, and scoped to the admin’s own paths — a framework that starts refusing requests to the application it was mounted into has exceeded its brief.

rate_limit={
    "read_per_minute": 600,    # GET: lists, forms, the dashboard
    "write_per_minute": 120,   # everything that writes
    "login_per_minute": 10,    # POST to sign-in, right password or wrong
}

Three budgets rather than one, because the three things an admin serves cost wildly different amounts. A list page is a query; a save is a transaction; a sign-in is an Argon2 hash — which is slow by design, and that makes the sign-in form the cheapest thing on any site to attack. A hash tuned to take 100 ms means eleven unauthenticated requests a second saturate a core, and the sender spends nothing. So the sign-in budget is the tightest, and it is charged in middleware before the handler runs: a refused request never reaches the hash at all. When the token API is mounted, POST /auth/token is charged to the same budget — it is the same hash behind a different door.

The defaults are loose on purpose. The budget is per address and an address is not a person: a whole office behind one NAT is one address, and a limit tuned to what one human does would throttle the fifth colleague to open a list. Ten reads a second sustained is far more than a room full of people clicking and far less than a scraper, which does not wait for its own responses.

A refused request is a 429 with Retry-After, X-RateLimit-Limit and Cache-Control: no-store. Static assets are never counted — one page pulls several of them, and counting those would turn one page view into six.

Same story as the refresh store for multiple workers — four processes each holding a quarter of the counters is four times the allowance:

fort.set_rate_limit_store(RedisRateLimitStore(...))

Behind a proxy

The budget is charged to a client address, and behind a reverse proxy the socket belongs to the proxy — so without being told, the limiter charges your entire traffic to one bucket.

security={"forwarded_depth": 1}   # one Caddy/nginx in front; 2 with a CDN too

X-Forwarded-For is written by whoever sent the request, so it is read from the right: the left is what the sender chose and each proxy appends after it. Counting from the right is the only reading an attacker cannot lengthen. Zero — the default — ignores the header entirely and uses the socket, which is always honest and wrong only in that it is the proxy’s.

Getting it too high is the dangerous direction: it reads an address the client wrote, and every request can then claim to be a new client with a fresh budget and a fresh lockout counter. Lockout and the limiter share this one function for exactly that reason.

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.

What an upload has to get past

Uploads come back out of /admin/media/…, which is the admin’s own origin — the origin the session cookie is scoped to. A file the browser can be persuaded to execute as a document is therefore not a curiosity; it is script running as whoever opens it, with their session. Four defences, because any one of them alone leaves the hole open.

The extension is allow-listed, and separately checked against a deny-list of everything a server or a browser might execute:

media={
    "allowed_extensions": {"pdf", "png", "jpg", "csv", "xlsx"},
    "allowed_image_extensions": {"png", "jpg", "jpeg", "webp"},
}

Written with or without a leading dot, in any case. An image field gets the narrower list, because photo meaning “a picture” and photo meaning “a zip archive” are different promises to whoever is looking at the thumbnail column.

A dangerous extension in the middle of a name stops being one. hack.exe.png is a name some servers read left to right and hand to an interpreter — the classic AddHandler bug — so it is stored as hack_exe.png and there is no .exe in it any more. Neutralised rather than refused: refusing every name with an extension buried in it also refuses example.com.pdf, and a rule that produces a mystery error for an ordinary file is a rule people work around. archive.tar.gz is untouched, because tar is not something anything executes.

The bytes are checked against the name. An extension is a claim by whoever uploaded the file. The leading magic numbers are read, and a .png whose content is an ELF binary, a shell script or an HTML document is refused with the mismatch named — the interesting attack is precisely the one where the two disagree.

Nothing is served on trust. The response’s Content-Type is decided from the stored bytes rather than the stored name, and anything not positively identified as a raster image is text/plain and a download. That covers files written before any of the above existed, or placed in the media root by something other than FastFort.

Sign-in records

Who signed in, from where, and on what. One call, against a table your project owns:

from fastfort.orm.sqlalchemy import SignInRecordMixin

class SignInRecord(SignInRecordMixin, Base):
    __tablename__ = "admin_sign_in"

fort.record_sign_ins(SignInRecord)

Every attempt becomes a row: when, the identity typed, whether it worked, the address, the browser, the platform, the kind of device, and the raw user-agent.

FastFort ships no migrations and creates no tables, which is why the model is yours — the mixin only supplies the columns, so a project that wants fewer declares fewer. Only at, successful and address are required, and the columns are checked when record_sign_ins is called, so a mistyped schema is a start-up error rather than a surprise at three in the morning.

Four decisions are worth knowing, because each is the opposite of the obvious implementation.

Client hints first, user-agent second. Chromium browsers send Sec-CH-UA, Sec-CH-UA-Platform and Sec-CH-UA-Mobile unprompted, and those are structured fields; the user-agent is a sentence that has been accreting since 1994. The raw string is stored beside the reading anyway, because every browser lies in it — Chrome claims to be Safari, which claims to be Gecko — and a reading can be wrong. Nothing in FastFort branches on any of it: a user-agent is written by whoever sent the request, so this is evidence for a person to read, never an input to a decision.

Failures are recorded too, unless you pass failures=False. A log of successes says nothing about the night somebody tried four hundred passwords.

There is no foreign key to the user table. The account is stored as text — the identity as typed, and the primary key as a string. An audit row that cascades away with the account is not an audit row, and “who deleted this account, from where” is exactly what these exist to answer.

Writing a record can never fail a sign-in. It runs in its own transaction, and a failure is logged through fastfort.auth rather than raised: a missing or full audit table must not be able to lock out the people who could fix it.

Where an address is

fort.record_sign_ins(SignInRecord, locate=my_geoip_lookup)

FastFort bundles no GeoIP database and calls no service. One would be a hundred megabytes going stale inside a wheel; the other would hand every administrator’s address to a third party from inside the login handler. If you have a database, locate is a callable taking an address and returning a string, and a callback that raises costs the place and nothing else.

Reading them

SignInRecord is an ordinary model, so register it like any other and you get a filterable, searchable, exportable page for free. kind is a fixed vocabulary — desktop, mobile, tablet, bot, unknown — so it can be a filter and a Breakdown rather than a column nobody can group by.

What the admin may do to an account

Four settings, for the deployment where “an administrator can do anything” is the wrong default — a public demo, a shared sandbox, a staging copy of production data:

FastFortSettings(
    auth={
        "allow_password_change": False,
        "allow_superuser_password_change": False,
        "allow_user_delete": False,
        "allow_superuser_delete": False,
    },
)
SettingDefaultWhat it stops
allow_password_changeTrueAny account’s password being changed through the admin
allow_superuser_password_changeTrueA superuser’s password being changed by somebody else
allow_user_deleteTrueAny account being deleted
allow_superuser_deleteTrueA superuser being deleted

They apply to the user model and to nothing else, and to every administrator equally — including the one who wrote them. None of this is a permission system. Rules that differ per person are authorisation, which is your own and plugs into the gate.

A protected password field is rendered read-only and dropped from the write, because a read-only control that still accepts a posted value is a label rather than a protection. A protected account is refused on the confirmation page and again on the press, since that page may have been open for an hour. A bulk delete keeps the protected rows, removes the rest, and says how many it kept.

A superuser can always change their own password: locking people out of their own credentials is a different feature.

On a public demo, reach for allow_password_change rather than the superuser variant. A demo that signs every visitor in as the same account makes each of them that account, and the narrower setting deliberately still allows changing your own password.

Before you deploy

uv run fastfort check --app main:fort --deploy

Or make start-up refuse:

settings.require_production_ready()