fastapi-startkit-auth
Passport-style OAuth2 + JWT authentication for FastAPI.
fastapi-startkit-auth brings the ergonomics of Laravel Passport
to FastAPI: a config-driven guard/provider/passwords model layered on top of
OAuth2 grants and signed JWT access tokens (per the
FastAPI security tutorial).
Features
| Area | What you get |
|---|---|
| Config layer | AuthConfig (default / guards / providers / passwords) + AuthProvider that registers into the app |
| Password grant | OAuth2 password grant → signed JWT access tokens with expiry (/oauth/token, /token) |
| Refresh tokens | Opaque refresh tokens with rotation and configurable TTL |
| Personal access tokens | Named, long-lived tokens with scopes/abilities |
| Client credentials | Machine-to-machine grant |
| Authorization code + PKCE | Browser/SPA flow with S256 & plain PKCE |
| Clients | Register / list / delete confidential & public clients |
| Revocation & introspection | RFC 7009 revoke + RFC 7662 introspect |
| Password reset | password_reset_tokens flow with configurable expire + throttle |
| Guards & deps | current_user, optional_user, require_scopes FastAPI dependencies |
| Pluggable providers | In-memory, generic ORM/masoniteorm model, or any custom UserProvider |
Installation
Requires Python 3.10+.
pip install fastapi-startkit-auth
# or
uv add fastapi-startkit-auth
Optional extras:
| Extra | Installs | Use when |
|---|---|---|
startkit |
fastapi-startkit[database]>=0.60,<1.0 (Python 3.12+) |
Registering AuthServiceProvider, publishing its config stub and migrations via provider:publish -p auth, and the "orm" stores |
masoniteorm |
masonite-orm |
Using the masoniteorm user provider driver |
pip install "fastapi-startkit-auth[startkit]"
pip install "fastapi-startkit-auth[masoniteorm]"
The startkit and masoniteorm extras are mutually exclusive: masonite-orm
pins cleo<2 while fastapi-startkit requires cleo>=2.1.
Uses
bcryptdirectly (notpasslib, which imports thecryptstdlib module removed in Python 3.13+), so it runs on modern Python.
Quickstart
from fastapi import Depends
from fastapi_startkit_auth import Application, AuthProvider, AuthConfig, current_user, require_scopes
from myapp.models import User # any active-record-style model
class Config(AuthConfig):
key = "change-me-to-a-long-random-secret" # JWT signing key
default = {"guard": "api", "passwords": "users"}
guards = {"api": {"driver": "passport", "provider": "users"}}
providers = {"users": {"driver": "masoniteorm", "model": User}}
passwords = {
"users": {"provider": "users", "table": "password_reset_tokens",
"expire": 60, "throttle": 60},
}
app = Application([(AuthProvider, Config)])
api = app.api # the underlying FastAPI instance
@api.get("/me")
def me(user=Depends(current_user)):
return user
@api.get("/reports")
def reports(ctx=Depends(require_scopes("reports:read"))):
return {"ok": True}
Serve it:
uvicorn myapp:app # Application is ASGI-callable
uvicorn myapp:app.api # or serve the FastAPI instance directly
Configuration
AuthConfig mirrors Laravel's config/auth.php and adds JWT/token knobs:
class AuthConfig:
default = {"guard": "api", "passwords": "users"}
guards = {"api": {"driver": "passport", "provider": "users"}}
providers = {"users": {"driver": "masoniteorm", "model": User}}
passwords = {"users": {"provider": "users", "table": "password_reset_tokens",
"expire": 60, "throttle": 60}}
# token settings (all optional, sensible defaults shown)
key = None # JWT secret (required in production)
algorithm = "HS256"
access_token_ttl = 3600 # seconds
refresh_token_ttl = 60 * 60 * 24 * 14
personal_access_token_ttl = 60 * 60 * 24 * 365
authorization_code_ttl = 600
bcrypt_rounds = 12
Provider drivers
| driver | meaning |
|---|---|
masoniteorm / orm / model |
wrap a model exposing find(id) and where(field, value).first() |
async_model |
same, for async ORMs where find, first() and save() are coroutines |
memory |
in-memory dict store (users=[...]) — great for tests/demos |
instance |
pass a ready UserProvider via {"instance": ...} |
factory |
pass a zero-arg callable returning a UserProvider |
Any object implementing the UserProvider protocol
(retrieve_by_id, retrieve_by_credentials, validate_credentials,
get_identifier, update_password) is a valid provider. Any of its methods
may be async def; one async method is enough for AuthManager to pick the
async guards, grants and broker. The sync classes refuse an awaitable result
with AsyncMisconfiguration instead of treating it as truthy.
Model-backed providers also accept password_key (the credentials key holding
the plaintext password, default: password_field) and is_active (a boolean
attribute name or a callable(user) -> bool; inactive users cannot log in,
authenticate, refresh or exchange a code, and introspect as inactive). The
async_model driver also takes an async def hook; the sync drivers reject
one at construction:
providers = {
"users": {
"driver": "async_model",
"model": User,
"password_field": "hashed_password",
"password_key": "password",
"is_active": "is_active",
}
}
Multiple providers
With more than one provider, register each OAuth client with the provider whose
users it serves (Laravel Passport's provider column). The password grant then
authenticates against that provider, and refresh, code exchange and
introspection re-check the token owner against it. A client without a
provider uses the default guard's provider, and /oauth/authorize rejects a
client bound to a different provider with unauthorized_client:
client, secret = manager.client_repository.register(name="admin-panel", provider="admins")
ORM stores and migrations
With an async provider or store, AuthManager builds the async guards, grants,
token service and password broker automatically (use the AsyncAuth facade for
session login/logout). Sessions, API tokens and OAuth tokens persist through the
fastapi-startkit ORM (pip install "fastapi-startkit-auth[startkit]", which pulls
in fastapi-startkit[database]). The package ships its own models
(fastapi_startkit_auth.orm) and uses no raw SQL:
session = {"store": "orm"}
api_tokens = {"store": "orm"}
tokens = {"store": "orm", "connection": "auth"} # optional ORM connection name
connection names an entry of your database config; omit it to use the default
connection. Publish and run the migrations once per app (they are reversible
and create the sessions, personal_api_tokens, oauth_access_tokens,
oauth_refresh_tokens and oauth_auth_codes tables with their indexes):
python artisan provider:publish -p auth # copies them to databases/migrations/
python artisan migrate
python artisan migrate:rollback # drops them again
Single-use guarantees rely on conditional UPDATE/DELETE statements issued
through the ORM query builder: refresh-token rotation and authorization-code
redemption each have exactly one winner under concurrency.
The former sql and async_sql stores were removed; configuring them raises a
ValueError pointing at "orm". Use "memory" or an "instance" store for
sync setups.
In mixed setups (async stores with a sync provider or a sync session store),
the sync calls — lookups, bcrypt, is_active hooks, a sync session store in
SessionMiddleware — run in the threadpool, never on the event loop.
An is_active attribute name or async def hook runs inline, since neither
blocks.
AuthProvider warms the providers up when the app starts (await manager.warm_up()), so AsyncModelUserProvider computes its dummy hash off the
event loop before the first request. A login for an unknown user then costs
one bcrypt verify, the same as a wrong password.
HTTP endpoints
| Method & path | Purpose |
|---|---|
POST /oauth/token |
Unified token endpoint: password, refresh_token, client_credentials, authorization_code |
POST /token |
Simple password grant (FastAPI-tutorial style) |
POST /oauth/authorize |
Approve an auth-code request (requires an authenticated user) → returns code |
POST /oauth/introspect |
RFC 7662 token introspection (requires client authentication) |
POST /oauth/revoke |
RFC 7009 access/refresh token revocation (requires client authentication) |
POST/GET /oauth/clients, DELETE /oauth/clients/{id} |
Client registration & management |
POST/GET /oauth/personal-access-tokens, DELETE .../{jti} |
Personal access tokens |
POST /password/email |
Trigger a password-reset token (delivered out-of-band; see below) |
POST /password/reset |
Reset the password with a token |
Password resets
POST /password/email always returns the same generic response whether or not
the account exists (no user enumeration) and never puts the token in the
response body. Configure how the token reaches the user with a notifier:
class AuthConfig(BaseAuthConfig):
password_reset_notifier = staticmethod(lambda email, token: send_email(email, token))
# debug_expose_reset_token = True # DEV ONLY: echo the token in the response
The code_challenge for public clients is mandatory — a public (secretless)
client cannot obtain an authorization code without PKCE, and codes are verified
against the code_verifier at exchange.
Example: password grant
curl -X POST localhost:8000/oauth/token \
-d grant_type=password -d username=ada@example.com -d password=secret -d scope="read write"
# → { "access_token": "...", "token_type": "Bearer", "expires_in": 3600,
# "refresh_token": "...", "scope": "read write" }
Example: authorization code + PKCE
POST /oauth/authorizewith a bearer token andcode_challenge→ returns a single-usecode.POST /oauth/tokenwithgrant_type=authorization_code, thecode, and thecode_verifier.
Scopes / abilities
Tokens carry scopes; enforce them with the require_scopes dependency:
require_scopes("posts:write") # must have this scope
require_scopes("a", "b") # must have all
require_scopes("a", "b", mode="any") # must have at least one
* is a wildcard scope that satisfies any check. Inside a handler you can also
inspect the AuthContext (ctx.can(...), ctx.can_any(...), ctx.scopes).
Testing
uv sync --group dev
uv run pytest
uv run ruff check .
The async store and flow tests run on aiosqlite; set TEST_ASYNCPG_DSN to a
disposable Postgres database to also run them on asyncpg (CI does).
Or with pip:
pip install -e ".[test]"
pytest
Releasing
Releases are cut from main with the release script (maintainers only):
./bin/release.sh # patch bump
./bin/release.sh minor # or: major
The script bumps the version (pyproject.toml, __version__, uv.lock),
builds sdist + wheel, validates them with twine check, uploads them to PyPI
with twine upload, then commits, tags vX.Y.Z, pushes, and creates a GitHub
release. Move the Unreleased notes in CHANGELOG.md under the new version
before running it.
It requires uv, an authenticated gh, and PyPI credentials for twine: either
a [pypi] entry in ~/.pypirc (username = __token__, password = pypi-...)
or TWINE_USERNAME=__token__ and TWINE_PASSWORD=pypi-... in the environment.
License
MIT
Metadata
Release files for fastapi-startkit-auth 0.6.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| fastapi_startkit_auth-0.6.1.tar.gz | 84.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| fastapi_startkit_auth-0.6.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 160.4 kB
Release files / fastapi_startkit_auth-0.6.1.tar.gz
| Download URL | fastapi_startkit_auth-0.6.1.tar.gz |
|---|---|
| Size | 84.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
347caa7f1a088cb2ec147daa38467be91c6af11f990789b4bb8682eee5c1a37d
|
|
BLAKE2b-256 checksum How to use checksums |
91ba2268b4cafcca32b699127a4f5e5172da1a1c73af2097d28a0bb32900cfa2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.7
|
Release files / fastapi_startkit_auth-0.6.1-py3-none-any.whl
| Download URL | fastapi_startkit_auth-0.6.1-py3-none-any.whl |
|---|---|
| Size | 76.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
780a4efdda6c7b256b6ed77c625f5b717e84bcebb95b9b8dc58c2c4a39aa4e3a
|
|
BLAKE2b-256 checksum How to use checksums |
b0f8c97cce6499e454e52ee4c306b823c7ac6fba71b4acb5e516322c458800d9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.7
|