Skip to main content

FastFort

Batteries-included authentication and admin framework for FastAPI.

Django-style model registration · Professional admin UI · Session + JWT auth · PostGIS & pgvector · SQLAlchemy & Tortoise · No Node.js required

CI PyPI License: MIT Python

Documentation · Live demo · Changelog

[!WARNING] Pre-1.0. The public API is not stable yet: before 1.0 a minor release may contain breaking changes, each one listed under a Breaking heading in the changelog. Pin a version.

The badge above is the released one. This README describes main, which may be ahead of it.


Why FastFort?

Every FastAPI project rebuilds the same things from scratch: an admin panel, a sign-in form, password hashing, a token endpoint, refresh rotation, CSRF, an upload field that cannot be tricked. Django ships that. FastAPI does not.

FastFort does — for an async stack, with no build step and no Node.js. What it does not ship yet is listed below, plainly, rather than left for you to find out after installing.

uv add "fastfort[sqlalchemy]"
# main.py
from fastapi import FastAPI
from fastfort import FastFort, FastFortSettings
from fastfort.orm.sqlalchemy import SQLAlchemyBackend

from app.db import Base, session_factory
from app.models import User

app = FastAPI()

fort = FastFort(
    settings=FastFortSettings(project_name="Shop"),
    backend=SQLAlchemyBackend(session_factory=session_factory, base=Base),
)
fort.set_user_model(User, identity_field="email")
fort.autodiscover("app")
fort.mount(app)
# app/products/admin.py
from fastfort import admin

from app.models import Product


@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
    list_display = ("id", "name", "price", "is_active", "created_at")
    list_filter = ("is_active", "category")
    search_fields = ("name", "description")
    ordering = ("-created_at",)
    select_related = ("category",)
    icon = "box"  # drawn beside the sidebar entry

    # Offered once rows are selected. "delete" is built in; this adds another.
    actions = ("delete", "archive")

    @admin.action("Archive", icon="box")
    async def archive(self, adapter, objects):
        for product in objects:
            await adapter.update(product, {"is_active": False})
        return f"{len(objects)} products archived."

Create the first account and start the server:

uv run fastfort generate-secret --export      # a signing key
FF_PASSWORD=... uv run fastfort createsuperuser \
    --identity you@example.com --password-env FF_PASSWORD --no-input
uv run uvicorn main:app

Open http://127.0.0.1:8000/admin, sign in, and you have a list, a search box, filters, sortable columns, numbered pagination, row selection with bulk actions, and working create, edit and delete pages. Foreign keys become searchable pickers -- backed by an autocomplete endpoint once the target table outgrows a dropdown -- and many-to-many fields become removable chips.

None of it needs JavaScript to work. Every control is a real form input and every sort header a real link; the browser-side code upgrades them in place and gets out of the way when it is not there.

Field names are checked against the model when the admin is built, so a typo in list_display is a start-up error naming every problem at once -- not a 500 the first time someone opens that page.

And a token API for the rest of your application

One setting, and the same user model and password hashes the admin uses are behind four routes in OAuth 2's shapes:

FastFortSettings(project_name="Shop", auth={"api_enabled": True})
POST /auth/token     identity + password  ->  access + refresh
POST /auth/refresh   refresh              ->  a new pair, retiring the old
POST /auth/logout    refresh              ->  204
GET  /auth/me        Bearer access        ->  who that is

The dependency is worth having even without the routes:

from fastfort.auth import bearer_user

current_user = bearer_user(fort)


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

It hands your route the user row, and rejects an account deactivated since the token was issued -- which a signature check alone would keep admitting until the token expired.


Features

📊 A dashboard you arrange fort.set_dashboard(Metric(Order), Trend(Order), Breakdown(Order, on="status"), Recent(Invoice), Counts()). Area charts, bar charts, sparklines with a signed delta, meters per value of a column — every one of them drawn by the server as SVG and boxes with a height. No charting library, no canvas, no new JavaScript, and each widget's docstring states how many queries it costs. Write your own by subclassing Widget
🕵 Sign-in records fort.record_sign_ins(SignInRecord) writes who signed in, from which address, on which browser and platform — failures included, because a log of successes says nothing about the night somebody tried four hundred passwords. Client hints first, user-agent second, raw string always kept. No foreign key to the user, so the row outlives the account it describes
🛡 Accounts a demo cannot break Four settings for what the admin may do to an account — change a password, change a superuser's password, delete an account, delete a superuser. All default to what the admin has always done. A protected password field is 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
🎛 Django-style admin @admin.register, list_display, list_filter, search_fields, fieldsets, actions
🗑 Deletes you can trust The confirmation counts what actually goes: rows that cascade, rows kept with the link cleared, and rows that block the delete outright — refused with a sentence instead of a constraint violation
🎨 A UI you will not want to replace Light and dark themes, brand colour from a single setting, ⌘K command palette, full keyboard navigation, real mobile layout
🔐 Production-grade auth Argon2id hashing, session cookies invalidated by a password change, login lockout with a growing delay, CSRF on every form, and a CSP that starts at default-src 'none'
🎫 A token API in one setting auth={"api_enabled": True} mounts /auth/token, /refresh, /logout and /me in OAuth 2's shapes. Refresh rotation with reuse detection: a token presented twice revokes the whole family. bearer_user(fort) is a Depends for your own routes, and it hands them the user row
🚦 Rate limiting, on by default Three budgets — reads, writes, and sign-in. Argon2 is slow by design, which makes the sign-in form the cheapest thing on any site to attack, so it is charged in middleware before the handler and a refused request never reaches the hash
📎 Uploads that are what they say Extension allow-list per field kind, a deny-list checked alongside it, magic numbers checked against the name, and a Content-Type at serve time decided from the bytes rather than the filename. hack.exe.png is stored as hack_exe.png
🗄 Three databases SQLite · PostgreSQL · MySQL, with identical behaviour
🔌 Two ORMs, one admin SQLAlchemy 2.0 and Tortoise ORM behind one adapter contract — and a conformance suite that asks both the same questions, so "the second one behaves the same" is a test rather than a claim
🧩 Every column type Text, numbers, money, dates, durations, UUIDs, JSON, hstore, arrays, enums, ranges and multiranges, inet/cidr/macaddr, bit strings — each with a real control, real validation and a filter where one makes sense. register_type adds your own
🗺 PostGIS All seven geometry kinds, drawn and edited on a hand-rolled slippy map — point, line, polygon with holes, and the multi-shapes. Spatial filters: within, intersects, and "5 km from here"
🧠 Vector search pgvector columns ranked by similarity — ?embedding__near=[…] with cosine, L2, L1 or inner product, a neighbour count and a distance bound
🌍 Eleven languages English, Uzbek, Russian, Turkish, German, French, Spanish, Chinese, Japanese, Korean and Arabic — the last of them right-to-left, which turns the whole layout around from one attribute. The catalogues ship in the package, so the admin is already translated the moment you install it — there is nothing to configure
⌨️ A CLI that matters createsuperuser so a fresh install has a way in, and check --deploy that exits non-zero
📤 Export and import The current view out as CSV, Excel or JSON — filters, search and ordering included. And back in again: the same parsers the form uses, foreign keys resolved by name or id, every bad cell reported at once with its line number, and nothing written unless the whole file parses. No openpyxl, no pandas
📦 No Node.js CSS and JavaScript are hand-written files inside the package, served Brotli-compressed with a gzip fallback. An ordinary page costs about 32 KB of CSS and 33 KB of script on the wire; the map and the data editors are separate files, fetched only by a page that needs them. A test weighs the lot and fails when it grows

How it is put together

UI (Jinja2 · CSS · vanilla JS)  ─┐
Admin (ModelAdmin, forms)       ─┤
Auth (sessions, tokens, CSRF)   ─┼──►  Spec layer (immutable, JSON-serialisable)  ◄── ORM adapters
Core (settings, registry)       ─┘

Everything above the spec layer is ORM-agnostic, and that boundary is enforced by tests rather than by convention. Adding a second ORM never touched the admin or the UI — fastfort/orm/tortoise/ was written without a change anywhere above fastfort/orm/, and a conformance suite asks both backends the same questions so that "the second one behaves the same" is a test rather than a claim.

There is no front-end framework. ui/static/js/ is one IIFE, no modules, no build step, and every control works with JavaScript switched off — sorting and filtering are links, everything else is a form. The script upgrades them in place; it never owns them.

Not in the box yet

fastfort/contrib/ is a placeholder. Roles and permissions beyond is_staff/is_superuser and soft delete are not implemented — the spec layer carries the ChangeSet an audit log would store and the field-masking it would need, but nothing writes one. Earlier versions of this README listed them as features. They were a plan.

Sign-in records are the one half of an audit log that now exists: fort.record_sign_ins(...) writes every attempt against a table your project owns. What it does not record is data changes — who edited which row.

Inline editing of related rows is the other gap worth naming — Django's InlineModelAdmin, editing an invoice's lines on the invoice's own page. A related model is reachable, and editable, on its own page.


Installation

uv add "fastfort[sqlalchemy,postgres]"    # SQLAlchemy on PostgreSQL
uv add "fastfort[sqlalchemy,mysql]"       # SQLAlchemy on MySQL
uv add "fastfort[sqlalchemy,sqlite]"      # SQLAlchemy on SQLite
uv add "fastfort[tortoise,postgres]"      # Tortoise on PostgreSQL
uv add "fastfort[all]"                    # everything

Requires Python 3.11 or newer. The ORMs are separate extras and neither is imported at package level, so installing one never pulls in the other.

Tortoise instead of SQLAlchemy

Four lines differ. Everything else — the settings, @admin.register, list_display, actions, export, import — is identical, because everything else is above the ORM layer and never sees a model:

from tortoise import Tortoise
from fastfort.orm.tortoise import TortoiseBackend

await Tortoise.init(
    db_url="postgres://…",
    modules={"models": ["app.models"]},
    # Tortoise 1.1 keeps its connections in a contextvar, and an ASGI server runs
    # the lifespan in a different task from the requests. Without this flag the
    # init above is invisible to every view: start-up looks healthy and the first
    # page that touches the database is a 500. `RegisterTortoise` from
    # `tortoise.contrib.fastapi` passes it for you.
    _enable_global_fallback=True,
)

fort = FastFort(settings=FastFortSettings(...), backend=TortoiseBackend())
fort.set_user_model(User)
fort.mount(app)

Development

The only prerequisite is uv. No Node.js, ever.

uv sync --all-extras          # set up the environment
uv run pytest                 # tests (SQLite)
uv run pytest --db=all        # tests against all three databases (needs Docker)
uv run ruff check .           # linting
uv run mypy fastfort          # type checking
make check                    # every gate at once

Two scratch applications come with the repository, and they are the fastest way to see any of this working:

make sandbox              # test_api/ on PostgreSQL — every column type,
                          #   PostGIS geometry, pgvector, on :8000
make sandbox-tortoise     # test_api_tortoise/ on SQLite — a different schema,
                          #   the same admin, on :8001

Open them side by side. The models, the ORM and the database all differ; the admin does not, which is the whole of what the layering buys.

See CONTRIBUTING.md before opening a pull request, and SECURITY.md to report a vulnerability.


License

MIT © Matnazar

Metadata

Release files for fastfort 0.7.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for fastfort 0.7.0
File Size Uploaded
fastfort-0.7.0.tar.gz 653.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for fastfort 0.7.0
File Interpreter ABI Platform
fastfort-0.7.0-py3-none-any.whl Python 3 none any Details

Total release size: 1.2 MB

Release files / fastfort-0.7.0.tar.gz

Download URL fastfort-0.7.0.tar.gz
Size 653.4 kB
Tags Source
SHA-256 checksum
How to use checksums
db71ab915d80d7db59a43480a0fd962be82126cec73b8a09fb870b6bf486d3f9
BLAKE2b-256 checksum
How to use checksums
16e331ced9e8cb1c1ea9780254f7ade4d96491e988dc34980461f93fb3056734
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 20, 2026.

Transparency log

Release files / fastfort-0.7.0-py3-none-any.whl

Download URL fastfort-0.7.0-py3-none-any.whl
Size 517.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7361c0423544def3a6999fc8e263a2da2808daf390cb74cdcd0125c8659c6a92
BLAKE2b-256 checksum
How to use checksums
a96705ffe01cc2b9876c4be64272a52549bd417ed94b9f3c7c850041c0d46fee
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 20, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.7.0 This release

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.4

2 release files

0.4.3

2 release files

0.4.2

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.2

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page