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:
| 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.
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,
},
)
| Setting | Default | What it stops |
|---|---|---|
allow_password_change | True | Any account’s password being changed through the admin |
allow_superuser_password_change | True | A superuser’s password being changed by somebody else |
allow_user_delete | True | Any account being deleted |
allow_superuser_delete | True | A 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()