Ergonomic, lightweight JWT authentication for FastAPI. Secure your routes instantly with plug-and-play dependency injection
Project description
FAuth
An ergonomic, plug-and-play authentication library for FastAPI.
fauth eliminates boilerplate around JWT, password hashing, user fetching, and Role-Based Access Control (RBAC) by leveraging FastAPI's Dependency Injection (Depends), Pydantic models, and Python Protocols.
Features
- Protocol-Based User Fetching — Complete inversion of control. You implement a simple
UserLoaderprotocol to define how to fetch a user from a token payload. - Plug-and-Play Configuration — Centralized settings via Pydantic (
AuthConfig). Configure once, inject everywhere. - Pluggable Transports — Extensible
Transportprotocol with a built-inBearerTransportfor Authorization header tokens. - Automatic OpenAPI/Swagger UI Support — Integrated security schemes that automatically show the "Authorize" button and security lock icons in Swagger UI.
- Built-in Password Hashing & Crypto — Modern Argon2 via
pwdliband utilities for creating/decoding JWT access and refresh tokens. - RBAC — Flexible
require_rolesandrequire_permissionsdependencies for endpoint authorization. - Secure Router —
SecureAPIRouterapplies authentication as a router-level dependency, securing all its routes automatically. - Structured Logging — Built-in
structlog-based logging for authentication events, token operations, and security failures. - Testing Utilities — Ships fake implementations (
FakeUserLoader) and abuild_fake_auth_provider()factory so consumers can write unit tests with zero boilerplate. - Type Safety — Fully annotated for MyPy and IDE integration.
Installation
pip install fauth
Or with uv:
uv add fauth
Quick Start
1. Define your user model
from pydantic import BaseModel
class User(BaseModel):
id: str
username: str
hashed_password: str
is_active: bool = True
roles: list[str] = []
permissions: list[str] = []
2. Implement the UserLoader and IdentityLoader protocols
FAuth uses a callback-based approach. You provide functions that retrieve users from your data source:
from fauth import TokenPayload, hash_password
# Your database, ORM, or any data source
DB: dict[str, User] = {
"user-123": User(
id="user-123",
username="alice",
hashed_password=hash_password("s3cret"),
roles=["admin"],
permissions=["read", "write"],
),
}
# UserLoader — resolves a user from a decoded JWT
async def load_user(payload: TokenPayload) -> User | None:
return DB.get(payload.sub)
# IdentityLoader — resolves a user by identifier (for password authentication)
async def load_identity(identifier: str) -> User | None:
return next((u for u in DB.values() if u.username == identifier), None)
3. Create the AuthProvider
from fauth import AuthConfig, AuthProvider
config = AuthConfig(secret_key="my-super-secret-key")
auth: AuthProvider[User] = AuthProvider(
config=config,
user_loader=load_user,
identity_loader=load_identity,
)
4. Wire it into FastAPI
from fastapi import FastAPI, Depends
app = FastAPI()
@app.post("/login")
async def login(username: str, password: str):
user = await auth.authenticate(username, password)
return await auth.login(sub=user.id)
@app.get("/me")
async def get_me(user: User = Depends(auth.require_user)):
return {"message": f"Hello {user.username}"}
That's it. The /login endpoint verifies credentials via authenticate(), then issues tokens via login(). The /me endpoint is protected — requests without a valid Bearer token will receive a 401 Unauthorized response.
Full Example
from fastapi import FastAPI, Depends, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel
from fauth import AuthConfig, AuthProvider, TokenPayload, SecureAPIRouter, hash_password
app = FastAPI()
# 1. Define your internal user model
class User(BaseModel):
id: str
username: str
hashed_password: str
is_active: bool = True
roles: list[str] = []
permissions: list[str] = []
# Mock databases
DB: dict[str, User] = {
"user-123": User(
id="user-123",
username="alice",
hashed_password=hash_password("s3cret"),
roles=["admin"],
permissions=["read", "write"],
)
}
# Identity lookup by username (used by authenticate)
IDENTITY_DB: dict[str, User] = {
"alice": DB["user-123"],
}
# 2. Define the callback that retrieves a user from the decoded JWT
async def load_user(payload: TokenPayload) -> User | None:
return DB.get(payload.sub)
# 3. Define the callback that retrieves a user by identifier (for password auth)
async def load_identity(identifier: str) -> User | None:
return IDENTITY_DB.get(identifier)
# 4. Instantiate the auth component
config = AuthConfig(secret_key="my-super-secret-key", algorithm="HS256")
auth: AuthProvider[User] = AuthProvider(
config=config,
user_loader=load_user,
identity_loader=load_identity,
)
# --- Routes ---
@app.post("/login")
async def login(username: str, password: str):
# 5. Verify credentials, then issue tokens
user = await auth.authenticate(username, password)
return await auth.login(sub=user.id)
@app.get("/me")
async def get_me(user: User = Depends(auth.require_user)):
# 6. `auth.require_user` secures the endpoint automatically
return {"message": f"Hello {user.username}"}
@app.get("/admin")
async def get_admin_data(user: User = Depends(auth.require_roles(["admin"]))):
# 7. `auth.require_roles` enforces RBAC with list of roles
return {"secret_data": "Top secret admin info"}
# --- Securing Multiple Routes ---
# 8. Use `SecureAPIRouter` to protect an entire group of routes.
# Any route added to this router will require an active user automatically.
# This also enables the "Authorize" button in Swagger UI!
secure_router = SecureAPIRouter(auth_provider=auth, prefix="/internal", tags=["Protected"])
@secure_router.get("/dashboard")
async def get_dashboard():
# This endpoint is secured by FAuth without needing Depends in the signature!
return {"data": "Secure dashboard"}
app.include_router(secure_router)
API Reference
AuthConfig
Centralized authentication settings, powered by pydantic-settings. Supports loading values from environment variables out of the box.
| Parameter | Type | Default | Description |
|---|---|---|---|
secret_key |
str |
required | Secret key used for signing JWTs |
algorithm |
str |
"HS256" |
JWT signing algorithm |
access_token_expire_minutes |
int |
15 |
Access token TTL in minutes |
refresh_token_expire_minutes |
int |
10080 (7 days) |
Refresh token TTL in minutes |
token_type |
Literal["bearer"] |
"bearer" |
Token type for responses |
from fauth import AuthConfig
# Minimal — only secret_key is required
config = AuthConfig(secret_key="my-secret-key")
# Full control
config = AuthConfig(
secret_key="my-secret-key",
algorithm="HS256",
access_token_expire_minutes=30,
refresh_token_expire_minutes=60 * 24, # 1 day
)
Since AuthConfig extends BaseSettings, you can also load from environment variables:
export SECRET_KEY="my-secret-from-env"
export ACCESS_TOKEN_EXPIRE_MINUTES=60
config = AuthConfig() # Reads from environment
AuthProvider[T]
The main orchestrator. Provides FastAPI dependencies for authentication and authorization.
Constructor
AuthProvider(
config: AuthConfig,
user_loader: UserLoader[T],
identity_loader: IdentityLoader[T] | None = None, # Required for authenticate()
transport: Transport | None = None, # Defaults to BearerTransport()
token_payload_schema: type[TokenPayload] = TokenPayload,
password_field_name: str = "hashed_password", # Attribute on user model holding the hash
)
Methods
| Method | Returns | Description |
|---|---|---|
require_user |
T |
FastAPI dependency — extracts and validates the token, loads the user |
require_active_user |
T |
Like require_user, but also checks user.is_active |
require_roles(roles) |
Callable |
Returns a dependency that demands the user has all specified roles |
require_permissions(perms) |
Callable |
Returns a dependency that demands the user has all specified permissions |
authenticate(identifier, password) |
T |
Verifies credentials via IdentityLoader + password check |
login(sub, scopes?, extra?) |
TokenResponse |
Issues access + refresh tokens for a given subject |
get_security_scheme() |
SecurityBase |
Returns the OpenAPI security scheme for docs |
UserLoader Protocol
Your application implements this to tell FAuth how to fetch a user from a decoded JWT:
from fauth import TokenPayload
# As a plain function
async def load_user(token_payload: TokenPayload) -> User | None:
return await db.get_user(token_payload.sub)
# Or as a callable class
class MyUserLoader:
def __init__(self, db: Database):
self.db = db
async def __call__(self, token_payload: TokenPayload) -> User | None:
return await self.db.get_user(token_payload.sub)
IdentityLoader Protocol
Used by authenticate() to look up a user by an identifier (username, email, etc.):
# As a plain function
async def load_identity(identifier: str) -> User | None:
return await db.get_user_by_username(identifier)
# Or as a callable class
class MyIdentityLoader:
def __init__(self, db: Database):
self.db = db
async def __call__(self, identifier: str) -> User | None:
return await self.db.get_user_by_username(identifier)
TokenPayload
The decoded JWT structure. Accepts extra claims via model_config = ConfigDict(extra="allow").
| Field | Type | Description |
|---|---|---|
sub |
str |
Subject (typically user ID) |
exp |
int |
Expiry timestamp |
iat |
int |
Issued-at timestamp |
jti |
str |
Unique token ID |
scopes |
list[str] |
Token scopes (defaults to []) |
token_type |
Literal["access", "refresh"] |
Distinguishes access from refresh tokens |
TokenResponse
Returned by auth.login():
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "bearer"
}
Crypto Utilities
FAuth exposes standalone functions for direct use outside the AuthProvider:
JWT
from fauth import create_access_token, create_refresh_token, decode_token, AuthConfig
config = AuthConfig(secret_key="my-secret")
# Create tokens
access = create_access_token(sub="user-123", config=config)
refresh = create_refresh_token(sub="user-123", config=config)
# With scopes and extra claims
access = create_access_token(
sub="user-123",
config=config,
scopes=["read", "write"],
extra={"tenant_id": "acme"},
)
# Decode
payload = decode_token(access, config)
print(payload.sub) # "user-123"
print(payload.token_type) # "access"
print(payload.scopes) # ["read", "write"]
Password Hashing
Uses Argon2 via pwdlib:
from fauth import hash_password, verify_password
hashed = hash_password("my-password")
is_valid = verify_password("my-password", hashed) # True
Authentication with Password Verification
FAuth provides a built-in authenticate() method on AuthProvider that handles credential verification using the IdentityLoader protocol and Argon2 password hashing.
Basic setup
from pydantic import BaseModel
from fauth import AuthConfig, AuthProvider, hash_password
class User(BaseModel):
id: str
username: str
hashed_password: str
is_active: bool = True
# Identity loader retrieves user by username/email/etc.
async def load_identity(identifier: str) -> User | None:
return await db.get_user_by_username(identifier)
# Token-based user loader (used by require_user)
async def load_user(payload) -> User | None:
return await db.get_user_by_id(payload.sub)
auth = AuthProvider(
config=AuthConfig(secret_key="my-secret"),
user_loader=load_user,
identity_loader=load_identity,
)
Using authenticate() in a login endpoint
from fastapi import FastAPI
app = FastAPI()
@app.post("/login")
async def login(username: str, password: str):
# Verifies password and checks is_active
user = await auth.authenticate(username, password)
# Issue tokens
return await auth.login(sub=user.id)
authenticate() performs three checks in order:
- User exists — looks up the user via
IdentityLoader. Raises401if not found. - Password is valid — verifies the plain password against the hashed password stored on the user. Raises
401if invalid. - User is active — checks
user.is_active(if the attribute exists). Raises401if inactive.
Custom password field
By default, authenticate() reads the hash from user.hashed_password. If your model stores it under a different attribute name, pass password_field_name:
class User(BaseModel):
id: str
pw_hash: str # non-standard field name
auth = AuthProvider(
config=config,
user_loader=load_user,
identity_loader=load_identity,
password_field_name="pw_hash",
)
Note: If
AuthProvideris created without anidentity_loader, callingauthenticate()will raise aRuntimeError.
Custom Token Payload
If you need custom claims in your tokens (e.g., tenant_id, organization_id), subclass TokenPayload and pass it to AuthProvider:
from fauth import AuthConfig, AuthProvider, TokenPayload
class MyTokenPayload(TokenPayload):
tenant_id: str
plan: str = "free"
auth = AuthProvider(
config=AuthConfig(secret_key="my-secret"),
user_loader=load_user,
token_payload_schema=MyTokenPayload, # JWTs will be decoded into MyTokenPayload
)
When issuing tokens, pass custom claims via the extra parameter:
await auth.login(sub="user-123", extra={"tenant_id": "acme", "plan": "pro"})
Your user_loader will then receive a MyTokenPayload instance with typed access to payload.tenant_id and payload.plan.
RBAC (Roles & Permissions)
Requiring Roles
@app.get("/admin")
async def admin_panel(user: User = Depends(auth.require_roles(["admin"]))):
return {"message": "Welcome, admin"}
Returns 403 Forbidden with {"detail": "Missing role: admin"} if the user lacks the role.
Requiring Permissions
@app.get("/reports")
async def reports(user: User = Depends(auth.require_permissions(["read", "reports"]))):
return {"data": "..."}
Returns 403 Forbidden with {"detail": "Insufficient permissions: requires read permission"} if the user lacks any of the required permissions.
Note: FAuth reads roles/permissions from
user.rolesanduser.permissionsattributes respectively. Make sure your user model exposes these fields.
Custom Transports
By default, FAuth uses BearerTransport, which extracts the token from the Authorization: Bearer <token> header. You can implement the Transport protocol to support other strategies (e.g., cookies):
from fastapi import Request, Response
from fastapi.security.base import SecurityBase
from fauth import Transport
class CookieTransport:
async def __call__(self, request: Request) -> str | None:
return request.cookies.get("auth_token")
def set_token_response(self, response: Response, token: str) -> None:
response.set_cookie("auth_token", token, httponly=True, samesite="lax")
def clear_token_response(self, response: Response) -> None:
response.delete_cookie("auth_token")
def get_security_scheme(self) -> SecurityBase:
# Return your custom OpenAPI scheme
...
# Use it
auth = AuthProvider(config=config, user_loader=load_user, transport=CookieTransport())
SecureAPIRouter
SecureAPIRouter is a drop-in replacement for APIRouter that automatically applies authentication to all its routes. It also registers the security scheme in OpenAPI so the "Authorize" button appears in Swagger UI.
from fauth import SecureAPIRouter
secure_router = SecureAPIRouter(
auth_provider=auth,
prefix="/api/v1",
tags=["Protected"],
)
@secure_router.get("/dashboard")
async def dashboard():
# Automatically secured — no Depends needed in the function signature
return {"data": "protected content"}
@secure_router.get("/settings")
async def settings():
return {"theme": "dark"}
app.include_router(secure_router)
Testing
FAuth ships a fauth.testing module to simplify testing. No complex JWT mocks or real database dependencies needed.
Dependency Override (recommended for unit tests)
import pytest
from fastapi.testclient import TestClient
from pydantic import BaseModel
from myapp.main import app, auth
from fauth.testing import build_fake_auth_provider
class User(BaseModel):
id: str
username: str
is_active: bool = True
roles: list[str] = []
@pytest.fixture
def test_client() -> TestClient:
# 1. Provide a mock test user
mock_user = User(id="user-123", username="test_user", roles=["admin"])
# 2. Wire the fake provider with an in-memory user store
fake = build_fake_auth_provider(users={"user-123": mock_user})
# 3. Override the dependency functions
app.dependency_overrides[auth.require_user] = fake.require_user
app.dependency_overrides[auth.require_active_user] = fake.require_active_user
yield TestClient(app)
# Clean up overrides
app.dependency_overrides.clear()
def test_secure_route(test_client):
response = test_client.get("/me")
assert response.status_code == 200
assert response.json() == {"message": "Hello test_user"}
End-to-end testing with real JWT tokens
If you prefer issuing real tokens in tests, build_fake_auth_provider uses safe test defaults (a fixed secret key, short expiry):
@pytest.mark.asyncio
async def test_secure_me_endpoint():
user = User(id="user-999", username="fake_alice", roles=[])
# FAuth supplies a pre-made test provider with safe defaults
test_auth = build_fake_auth_provider(users={"user-999": user})
# Generate a real JWT token via the test provider
token_response = await test_auth.login(sub="user-999")
# Override the dependency so the app uses the test user store
app.dependency_overrides[auth.require_user] = test_auth.require_user
# Apply Bearer token
client = TestClient(app)
response = client.get(
"/me",
headers={"Authorization": f"Bearer {token_response.access_token}"}
)
assert response.status_code == 200
assert response.json() == {"message": "Hello fake_alice"}
app.dependency_overrides.clear()
Testing utilities reference
| Import | Description |
|---|---|
build_fake_auth_provider(users?, config_overrides?) |
Creates an AuthProvider backed by in-memory fakes |
fake_auth_config(**overrides) |
Returns an AuthConfig with safe test defaults |
FakeUserLoader[T] |
In-memory UserLoader — populate with .add_user(id, user) |
FakeIdentityLoader[T] |
In-memory IdentityLoader — populate with .add_user(id, user) |
Structured Logging
FAuth uses structlog for structured logging across all security-sensitive operations. FAuth does not call structlog.configure() — your application owns the processor pipeline. If you never configure structlog, the default dev renderer is used (coloured, human-readable text).
Configuring log output
Configure structlog once in your application startup — FAuth (and any other structlog-based library) will follow:
import structlog
# Development — human-readable coloured text
structlog.configure(
processors=[
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S", utc=False),
structlog.dev.ConsoleRenderer(),
],
)
# Production — JSON lines for log aggregators (Datadog, ELK, etc.)
structlog.configure(
processors=[
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.JSONRenderer(),
],
)
What gets logged
| Event | Level | Context |
|---|---|---|
login_token_issued |
info |
sub |
token_decoded |
debug |
sub, token_type |
user_authenticated |
debug |
sub |
authentication_failed |
warning |
reason (missing_token, token_expired, invalid_token, user_not_found) |
authorization_failed |
warning |
reason (inactive_user, missing_role, missing_permission) |
Example log output
With ConsoleRenderer() (default):
2026-04-01 10:30:15 [info ] login_token_issued sub=user-123
2026-04-01 10:30:16 [debug ] token_decoded sub=user-123 token_type=access
2026-04-01 10:31:00 [warning ] authentication_failed reason=token_expired
With JSONRenderer():
{"sub": "user-123", "event": "login_token_issued", "level": "info", "timestamp": "2026-04-01T13:30:15Z"}
{"reason": "token_expired", "event": "authentication_failed", "level": "warning", "timestamp": "2026-04-01T13:31:00Z"}
Error Handling
FAuth raises HTTPException with standard HTTP status codes:
| Scenario | Status Code | Detail |
|---|---|---|
| Missing token | 401 |
"Not authenticated" |
| Expired token | 401 |
"Token expired" |
| Invalid/malformed token | 401 |
"Invalid token" |
| User not found | 401 |
"User does not exist" |
| Invalid credentials | 401 |
"Invalid credentials" (from authenticate()) |
| Inactive user (token) | 400 |
"Inactive user" (from require_active_user) |
| Inactive user (login) | 401 |
"Inactive user" (from authenticate()) |
| Missing role | 403 |
"Missing role: {role}" |
| Missing permission | 403 |
"Insufficient permissions: requires {permission} permission" |
For programmatic exception handling, FAuth also exposes:
from fauth import FAuthError, InvalidTokenError, TokenExpiredError
These are raised by the crypto layer (decode_token) and can be caught independently of HTTP responses.
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file fauth-0.4.0.tar.gz.
File metadata
- Download URL: fauth-0.4.0.tar.gz
- Upload date:
- Size: 73.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e901f6962a6115e351a20b1950f1821ad234a24b9d9f3c65e4240299eb0498d2
|
|
| MD5 |
4ebba6b78817a3a836343ccb15016b57
|
|
| BLAKE2b-256 |
fcf186f5f9f2fe56a28fc985a863473e07a3bfc90df8bf167e0e841f7c745b8d
|
Provenance
The following attestation bundles were made for fauth-0.4.0.tar.gz:
Publisher:
cicd.yml on justmatias/fauth
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
fauth-0.4.0.tar.gz -
Subject digest:
e901f6962a6115e351a20b1950f1821ad234a24b9d9f3c65e4240299eb0498d2 - Sigstore transparency entry: 1312210062
- Sigstore integration time:
-
Permalink:
justmatias/fauth@3c9d7060cdf18251cd1ec7530b71a1d4883924ac -
Branch / Tag:
refs/heads/main - Owner: https://github.com/justmatias
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
cicd.yml@3c9d7060cdf18251cd1ec7530b71a1d4883924ac -
Trigger Event:
push
-
Statement type:
File details
Details for the file fauth-0.4.0-py3-none-any.whl.
File metadata
- Download URL: fauth-0.4.0-py3-none-any.whl
- Upload date:
- Size: 22.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2c153759bd794cacc2da967636c9fcb6d18e06bb31e26fd79f47da09f07236bf
|
|
| MD5 |
528c9bfcfda7480105394532408549d7
|
|
| BLAKE2b-256 |
eff84c087fa8470df219e205b8dc8cbe2fad9e02e3f1244f1b898d3aa851b7e9
|
Provenance
The following attestation bundles were made for fauth-0.4.0-py3-none-any.whl:
Publisher:
cicd.yml on justmatias/fauth
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
fauth-0.4.0-py3-none-any.whl -
Subject digest:
2c153759bd794cacc2da967636c9fcb6d18e06bb31e26fd79f47da09f07236bf - Sigstore transparency entry: 1312210159
- Sigstore integration time:
-
Permalink:
justmatias/fauth@3c9d7060cdf18251cd1ec7530b71a1d4883924ac -
Branch / Tag:
refs/heads/main - Owner: https://github.com/justmatias
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
cicd.yml@3c9d7060cdf18251cd1ec7530b71a1d4883924ac -
Trigger Event:
push
-
Statement type: