A strictly typed, highly decoupled, enterprise-grade authentication foundation utilizing Hexagonal Architecture.
This template provides a strict, organized foundation to handle authentication workflows. By utilizing Domain-Driven Design (DDD) and Hexagonal Architecture, the business logic remains pristine and uncoupled from infrastructure (FastAPI, SQLAlchemy, Redis).
It is designed with security and enterprise-readiness in mind, featuring:
- Strict Type Safety: Enforcement of core domain models like
UUIDandEmailStracross all boundaries. - Advanced Session Management: A dual-token architecture (HttpOnly Refresh Cookies + JWT Access Tokens) with lazy token rotation, session families, and remote device revocation.
- Asynchronous Background Processing: Built-in
TaskRunnerPortfor non-blocking operations like sending emails and writing system logs, easily swappable with Celery or RabbitMQ.
Infrastructure Independence: Out of the box, this template is SQL-based (using SQLAlchemy & Alembic), but because the system is deeply modular, you are not locked in! You can easily swap out the SQL database, cache, or email provider by writing a new adapter. See 6. π οΈ How to Change Core Infrastructure to learn how.
You can safely drop this into your new projects and focus immediately on building your core features, knowing the foundation is secure and highly decoupled.
Tip
React Frontend Companion
We have built an official reference implementation demonstrating how to securely consume this API (handling stateless JWTs, silent token rotation, and CSRF protection). Check it out here: Avneesh11905/Vite_React_OAuth_Frontend
- π Introduction
- π Table of Contents
- 1. ποΈ Architecture Overview
- 2. π Getting Started
- 3. βοΈ Environment Variables Guide
- 4. π Authentication Workflows
- 5. π» Frontend Integration Guidelines
- 6. π οΈ How to Change Core Infrastructure
- 7. π Adding an OAuth Provider
- 8. π Integrating Authorization
- 9. π§ Email Templates & Developer Previews
- 10. βοΈ Background Task Processing
- 11. π§ͺ Testing
- 12. π¨ Production Deployment Checklist
The project is structured into modular domains using Domain-Driven Design (DDD) and Hexagonal Architecture. This ensures business logic remains pristine and uncoupled from infrastructure (FastAPI, SQLAlchemy, Redis).
src/shared/: The backbone of the application. It contains infrastructure that spans across all domains, such as database connections (get_db), caching clients, the email configuration pipeline, application lifecycle events, and global exception handlers.src/authentication/: Handles identity verification. It manages local registration, OAuth integrations, password resets, email verification, and issues JWTs.src/users/: Manages the user profile lifecycle (fetching profiles, updating display names, deleting accounts) independently from the authentication logic.src/authorization/: Contains the business rules for access control (RBAC/PBAC) and injects permissions into your JWTs.
Each domain (except shared) is divided into distinct, decoupled layers:
- Core (
core/): Contains pure Python business rules and Use Cases. It has zero knowledge of FastAPI, SQLAlchemy, or external APIs. - Ports (
core/ports/): Abstract interfaces (typing.Protocol) that define external dependencies required by the Core (e.g.,CachePort,EmailSenderPort). - Adapters (
adapters/): Concrete implementations of the Ports (e.g.,RedisCacheAdapter,SQLUserRepositoryAdapter). - API (
api/): FastAPI routes acting as the entry point. They translate HTTP requests into Python objects, execute Use Cases, and return HTTP responses.
Tip
Dependency Injection: A centralized Composition Root (api/container.py inside each domain) instantiates all Adapters. This is bridged with FastAPI's native DI system using typing.Annotated, allowing routes to depend cleanly on use cases.
This project enforces the Unit of Work (UoW) pattern to manage database transactions cleanly:
- Routes inject the
SQLAlchemyUnitOfWorkand wrap use case execution in anasync with uow:block. - Repositories never call
commit()directly; they only perform data manipulation andflush(). - The UoW automatically commits the transaction at the end of the block if successful, or rolls back if an exception occurs, ensuring atomicity across multiple repository operations.
Note on Architecture: Because this template strictly follows Clean Architecture, the default tech stack (PostgreSQL, Redis, Resend) is completely decoupled from the core logic and is 100% swappable. You can easily replace the database, cache, or email provider by writing a new adapter. See 6. π οΈ How to Change Core Infrastructure for a guide.
- Python 3.12+
- Database: PostgreSQL (for production) or SQLite (built-in fallback for local development).
- Cache: Redis (recommended for production) or Memory (built-in fallback for local development).
Option A: Using Docker (Recommended)
- Copy the configuration:
cp .env.example .env
- Generate Security Keys (RS256):
Copy the output and paste it into your
uv run python scripts/generate_keys.py
.envfile forJWT_PRIVATE_KEYandJWT_PUBLIC_KEY. - Spin up the entire stack (API, PostgreSQL, Redis) with a single command:
docker compose up --build
Option B: Local Python Setup (Using uv)
- Ensure you have
uvinstalled. - Clone the repository and install dependencies:
uv sync # Or, if using requirements.txt: uv venv && uv pip install -r requirements.txt - Copy the configuration:
cp .env.example .env
- Generate Security Keys (RS256):
uv run python scripts/generate_keys.py
- Choose your Cache: The system defaults to
MemoryCacheAdapterfor local dev (which will log a warning). To use Redis, updatesrc/shared/container.pyto instantiateRedisCacheAdapter(client=redis_client). - Run database migrations:
uv run alembic upgrade head
- Start the server:
uv run python runserver.py
The .env file controls the entire behavior of the application without needing to touch code. Here is what every variable does:
| Variable | Example | Description |
|---|---|---|
FRONTEND_URL |
"http://localhost:3000" |
Used to build deep links (like password reset URLs) sent in emails. |
PROJECT_NAME |
"FastAPI OAuth" |
The title shown in Swagger UI and the sender name in some emails. |
ENV |
"development" |
When "development": Enables development routes (like the email gallery), Swagger UI, and automatically adds local URLs (http://localhost:3000, 5173, 8000) to the allowed CORS origins.When "production": Disables these development features and enforces strict cross-origin policies. |
CORS_ORIGINS |
"https://myapp.com" |
Comma-separated list of allowed frontend URLs. Crucial for security. (Note: If ENV="development", http://localhost:3000, 5173, and 8000 are automatically whitelisted, so you do not need to add them here). |
SESSION_SECRET |
"super_secret_string" |
Used to cryptographically sign the X-CSRF state validation. |
JWT_PRIVATE_KEY |
"-----BEGIN RSA PRIVATE KEY-----..." |
Used to cryptographically sign the Access Tokens. |
JWT_PUBLIC_KEY |
"-----BEGIN PUBLIC KEY-----..." |
Used to verify the Access Tokens. |
| Variable | Example | Description |
|---|
| LOG_RETENTION_DAYS | 28 | How many days of system logs to keep in the database before the background worker deletes them. |
| DB_ASYNC_URL | postgresql+asyncpg://... | Connection string to your database. Optional. If omitted, falls back to a local SQLite database (sqlite+aiosqlite:///./auth.db). |
| CACHE_URL | "redis://localhost:6379/0" | Connection string to your Cache & Rate Limiting server. Optional. Defaults to redis://localhost:6379/0. Can be safely swapped to memcached://... without breaking the system. |
| Variable | Example | Description |
|---|---|---|
EMAIL_API_KEY |
"re_123456789" |
Your Resend API key. |
EMAIL_FROM |
"onboarding@resend.dev" |
The email address shown to users. Must be verified with your provider. |
EMAIL_TEMPLATE_NAME |
"modern" |
Select the visual theme for all outbound emails (modern, minimal, playful). |
| Variable | Example | Description |
|---|---|---|
GOOGLE_CLIENT_ID |
"1234.apps.googleusercontent.com" |
From the Google Cloud Console. |
GOOGLE_CLIENT_SECRET |
"GOCSPX-1234" |
From the Google Cloud Console. |
GITHUB_CLIENT_ID |
"Iv1.1234" |
From GitHub Developer Settings. |
GITHUB_CLIENT_SECRET |
"abc1234" |
From GitHub Developer Settings. |
| Variable | Example | Description |
|---|---|---|
TOKEN_ACCESS_TOKEN_LIFETIME_MINUTES |
15 |
How long the stateless JWT is valid. |
TOKEN_REFRESH_TOKEN_LIFETIME_DAYS |
7 |
How long a user stays logged in before being forced to re-authenticate. |
VERIFICATION_OTP_EXPIRATION_SECONDS |
300 |
How long a 6-digit OTP is valid after being issued. |
VERIFICATION_OTP_RESEND_WINDOW_SECONDS |
900 |
The window within which OTP resend is allowed (keeps the pending registration alive). |
VERIFICATION_OTP_MAX_ATTEMPTS |
5 |
Maximum number of wrong OTP attempts before the flow is locked out and a new OTP must be requested. |
VERIFICATION_PASSWORD_RESET_EXPIRY_SECONDS |
900 |
How long a password reset token is valid after being issued. |
RATE_LIMIT_LOGIN_RATE_LIMIT |
"5/minute" |
Strict slow-down on the login endpoints to prevent brute forcing. |
RATE_LIMIT_DEFAULT_RATE_LIMIT |
"60/minute" |
Default API limit. |
Note
Prefix change: OTP and password-reset settings previously used the TOKEN_ prefix. They were moved to a dedicated VerificationSettings class and now use the VERIFICATION_ prefix.
A database-first registration flow prevents malicious actors from claiming emails they don't own.
- The Flow: A user is saved immediately with
is_verified=False. If they do not verify their email using the 6-digit OTP within 5 minutes, the OTP expires. - Retry Logic: If the user abandons the flow and tries to register again hours later, the backend gracefully accepts it, updates their pending password, and dispatches a fresh OTP.
- Brute-Force Protection: The OTP flow implements atomic counting and strictly locks the account registration process after 5 failed attempts (requiring a new OTP request).
- Garbage Collection: To prevent database bloat from bots, a background task automatically purges unverified user accounts older than 24 hours.
sequenceDiagram
actor User
participant Frontend
participant AuthAPI as Auth Service
participant Database
participant Email
User->>Frontend: Submits Email & Password
Frontend->>AuthAPI: POST /auth/register
AuthAPI->>Database: Save User (is_verified=False)
AuthAPI->>Email: Dispatch 6-digit OTP
AuthAPI-->>Frontend: 201 Created
User->>Frontend: Submits OTP
Frontend->>AuthAPI: POST /auth/verify-email
AuthAPI->>Database: Mark is_verified=True
AuthAPI-->>Frontend: 200 OK
A dual-token system is utilized for security:
- Refresh Token: 32-byte hash saved in the DB, sent to the client as an
HttpOnlySecure cookie. - Access Token: Short-lived (15m) RS256 JWT returned in the JSON payload from the
/refreshendpoint.
To keep the frontend logic DRY, the /login endpoints use a "Silent Auth" pattern where they only issue the Refresh Cookie, forcing the frontend to immediately call /refresh to get the access token.
sequenceDiagram
actor User
participant Frontend
participant AuthAPI as Auth Service
participant Database
User->>Frontend: Enters Credentials
Frontend->>AuthAPI: POST /auth/login/local
AuthAPI->>Database: Validate Hash & is_verified
AuthAPI->>Database: Generate & Save Refresh Token
AuthAPI-->>Frontend: Set-Cookie: refresh_token (HttpOnly)
Note over Frontend,AuthAPI: Frontend automatically fetches access token
Frontend->>AuthAPI: POST /auth/refresh (Cookie)
AuthAPI->>Database: Rotate Refresh Token
AuthAPI-->>Frontend: Set-Cookie: new_refresh_token
AuthAPI-->>Frontend: JSON: access_token (JWT)
The OAuth flow relies on redirects. Once the provider confirms identity, the backend sets the HttpOnly cookie and redirects the browser back to the frontend. The frontend then calls /refresh on boot.
sequenceDiagram
actor User
participant Frontend
participant AuthAPI as Auth Service
participant Provider as Google/GitHub
User->>Frontend: Clicks "Login with Google"
Frontend->>AuthAPI: GET /auth/login/google
AuthAPI-->>User: 302 Redirect to Provider
User->>Provider: Grants permission
Provider-->>AuthAPI: 302 Redirect back to /callback/google?code=...
AuthAPI->>Provider: Exchanges code for Profile
AuthAPI->>Database: Create/Update User & Generate Refresh Token
AuthAPI-->>Frontend: 302 Redirect to Frontend URL + Set-Cookie: refresh_token (HttpOnly)
Note over Frontend,AuthAPI: Frontend automatically fetches access token on boot
Frontend->>AuthAPI: POST /auth/refresh (Cookie)
AuthAPI-->>Frontend: JSON: access_token (JWT)
To mitigate token theft, the system implements lazy Refresh Token rotation. Rather than rotating the token on every single call (which causes unnecessary DB writes), the token is only rotated when it has β€ 30% of its lifetime remaining. Most /refresh calls simply re-validate the existing token and issue a new Access Token without touching the Refresh Token at all.
sequenceDiagram
participant Frontend
participant AuthAPI as Auth Service
participant Database
Frontend->>AuthAPI: POST /auth/refresh (Cookie)
AuthAPI->>Database: Validate Token (check used=False, not expired)
alt Token lifetime > 30% remaining
AuthAPI-->>Frontend: Set-Cookie: same refresh_token (no rotation)
else Token lifetime β€ 30% remaining
AuthAPI->>Database: Mark old token used=True
AuthAPI->>Database: Generate & Save NEW Refresh Token
AuthAPI-->>Frontend: Set-Cookie: new_refresh_token
end
AuthAPI-->>Frontend: JSON: new_access_token (JWT)
Logout and device revocation use two complementary mechanisms:
- Access Token (
jti) blacklist β Because JWTs are stateless, the current access token's unique ID (jti) is written to the cache with a TTL equal to its remaining lifetime. Any subsequent request bearing that token is rejected immediately, even before it expires naturally. - Refresh Token soft-invalidation β On logout, device revocation (
DELETE /auth/sessions/{family_id}), password changes, or password resets, all refresh tokens in the session family are markedused=Truein the database. No Redisblacklist:family:*key is written. Active access tokens from revoked sessions expire naturally withinACCESS_TOKEN_LIFETIME_MINUTES(default 15 min).
Note
Multi-worker note: The per-jti blacklist still requires a shared cache (Redis) in multi-worker deployments. The family revocation check is DB-only and works correctly across workers without Redis.
Building the frontend should be the fun part! Here is exactly what you need to wire up to get this authentication system humming.
You only have to build two routes on your frontend to handle the core flows:
- π
/(The Root Route): Make sure your root route can handle post-login redirects and email verification success states. - π
/reset-password: This is where users land when they click the "Reset Password" link in their email.- The Inbound Catch: The user arrives via
GET /reset-password?token=YOUR_SECURE_TOKEN. Your frontend needs to parse thattokenright out of the URL. - The Outbound Pitch: Show them a nice form asking for a new password. When they hit submit, shoot a
POSTrequest back to our backend at/auth/password/resetwith this exact JSON payload:{"token": "YOUR_SECURE_TOKEN", "new_password": "the_new_password"}.
- The Inbound Catch: The user arrives via
Important
Token Mechanics: The backend returns the Access Token in the JSON body, which you must attach as Authorization: Bearer <token> to protected API requests. The Refresh Token is set as a secure, HttpOnly cookieβso the browser handles it completely automatically!
Warning
Account Deletion β 30-day Recovery Window: DELETE /users/me performs a soft-delete β the account is immediately deactivated (the user cannot log in) and is scheduled for permanent purge after 30 days. Within that window, the user can recover their account simply by logging in again. When the user logs in to recover their account, a security notification email is automatically dispatched. After 30 days, the account and all associated data (OAuth links, passwords, sessions) are permanently removed by a background cleanup task. Ensure your frontend clearly communicates this recovery window before calling this endpoint.
Tip
Interactive Documentation: Run the backend and visit http://localhost:8000/docs for the auto-generated Swagger UI, or http://localhost:8000/redoc for ReDoc. You can test endpoints and see exactly what the JSON responses look like!
Here is your treasure map to the backend API.
| Method | Endpoint | Description |
|---|---|---|
POST |
/auth/register |
Creates a new user (is_verified=False) and dispatches a 6-digit OTP email. |
POST |
/auth/verify-email |
Validates the OTP, unlocks the account, and auto-logs the user in β sets refresh_token + csrf_token cookies on success. The frontend should immediately call POST /auth/refresh to obtain the Access Token. |
POST |
/auth/verify-email/resend |
Lost the code? Generates and emails a brand new OTP. |
POST |
/auth/login/local |
Authenticates with email/password. Boom! You've got an HttpOnly session cookie! (Call /auth/refresh for the JWT). |
GET |
/auth/login/{provider} |
Redirects the user to an OAuth provider (e.g., /auth/login/google). |
GET |
/auth/callback/{provider} |
Handles the OAuth provider redirect and establishes the session. |
POST |
/auth/refresh |
Rotates the HttpOnly Refresh Token cookie and issues a fresh JWT. |
POST |
/auth/password/forgot |
Starts the "Forgot Password" flow. Emails a link with a short-lived reset token. |
POST |
/auth/password/reset |
Completes the "Forgot Password" flow. Accepts the token and a new password. |
PATCH |
/auth/password |
Updates the authenticated user's password. (Requires X-CSRF header) |
POST |
/auth/logout |
Blacklists the current JWT and destroys the session cookies. |
GET |
/auth/sessions |
Lists all active device sessions for the user, including the auth_provider (e.g. local, google). Perfect for a "Security" settings page. |
DELETE |
/auth/sessions/{family_id} |
Revokes a specific remote session, instantly logging that device out. |
| Method | Endpoint | Description |
|---|---|---|
GET |
/users/me |
Fetches the currently authenticated user's profile data. Note: This endpoint implements Lazy Caching via Redis/Memory, resulting in zero database hits for successive calls. Cache is automatically invalidated upon updates. |
PATCH |
/users/me |
Updates display name, profile picture, or the receive_updates opt-in preference. (Requires X-CSRF header). |
DELETE |
/users/me |
Soft-deletes the user's account. The account is immediately deactivated and scheduled for permanent purge after 30 days. Recoverable within that window by logging in again. Also blacklists the current JWT and clears the session cookie. (Requires X-CSRF header). |
Nobody likes being randomly logged out. Use an HTTP interceptor to automatically catch 401 Unauthorized responses, silently call the /auth/refresh endpoint to get a new Access Token, and retry their pending request transparently!
Tip
Pro-tip: Here's a copy-paste ready Axios interceptor to handle token rotation seamlessly.
axios.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// If it's a 401 and we haven't already retried this exact request...
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// Silently request a new token using the HttpOnly Refresh Token cookie!
const { data } = await axios.post('/auth/refresh');
// Update headers and retry the original request
axios.defaults.headers.common['Authorization'] = `Bearer ${data.access_token}`;
originalRequest.headers['Authorization'] = `Bearer ${data.access_token}`;
return axios(originalRequest);
} catch (refreshError) {
// If the refresh fails, their session is dead. Boot them to login.
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);To prevent Cross-Site Request Forgery (CSRF), state-changing operations on sensitive endpoints (like PATCH /users/me or DELETE /users/me) require an X-CSRF header.
How it works:
- When a user authenticates, the backend automatically sets a secure,
HttpOnlysession cookie (the Refresh Token), and also sets a standardcsrf_tokencookie. - Because the
csrf_tokencookie is notHttpOnly, your frontend JavaScript (or Axios) can read it usingdocument.cookie. - When making a state-changing request, your frontend must extract this token from the cookie and attach it as the
X-CSRFheader. - The backend verifies that the header matches the internal state, confirming the request originated from your actual frontend and not a malicious third-party site.
One of the greatest strengths of this template is its plug-and-play nature. Because the Core business logic only communicates through Ports, you can completely replace any infrastructure by simply writing a new Adapter.
The template ships with two built-in cache adapters in the shared kernel: MemoryCacheAdapter and RedisCacheAdapter. To switch between them, open src/shared/container.py and instantiate the one you want.
To plug in a completely different backend (e.g., Memcached), follow the universal 3-step pattern in 6.5 using the shared CachePort as your interface.
Tip
Automatic Rate Limiter Sync
The built-in rate limiter (slowapi) automatically connects to whatever you specify in CACHE_URL. If you swap to Memcached, simply set CACHE_URL="memcached://localhost:11211" and both your custom Cache Adapter and the Rate Limiter will seamlessly switch over!
To drastically simplify Developer Experience, email provider logic lives in the shared kernel.
Currently, the template uses ResendEmailClient. To swap it:
- Create a new file:
src/shared/adapters/sendgrid_email_client.py. - Implement the
SharedEmailClientPortprotocol:from src.shared.core.ports.email_client import SharedEmailClientPort class SendGridEmailClient: def send_email(self, to: str, subject: str, html: str) -> None: # SendGrid dispatch logic here (sync β called via run_in_executor) pass
- Update the Composition Root in
src/authentication/api/container.pyto use your new adapter:# Old: from src.shared.adapters.resend_email_client import ResendEmailClient # Old: email_client = ResendEmailClient(...) from src.shared.adapters.sendgrid_email_client import SendGridEmailClient email_client = SendGridEmailClient()
Done! You don't have to touch Jinja2 templates or the authentication domain's business logic. All auth emails will seamlessly start using SendGrid.
Because all database queries are abstracted behind Repository Ports, replacing PostgreSQL with MongoDB is straightforward:
- Change
DB_ASYNC_URLin your.envto your MongoDB connection string. - Edit
src/shared/config/database.pyandsrc/shared/api/dependencies.pyto yield a MongoDB async client instead of an SQLAlchemyAsyncSession. - Create a new adapter
src/authentication/adapters/mongo_user_repository.pythat implementsUserRepositoryPort. - Plug it into
src/authentication/api/container.py.
Want to use RabbitMQ instead of the built-in AsyncioTaskRunner?
- Define the Port: The interface is already defined at
src/shared/core/ports/task_runner.py. - Create the Adapter: Create
src/shared/adapters/rabbitmq_task_runner.pythat implements theTaskRunnerPort. - Plug it in: Open
container.pyand swapAsyncioTaskRunner()withRabbitMQTaskRunner(). Every domain will instantly start sending background tasks to your RabbitMQ queue.
The examples above follow the exact same 3-step recipe that applies to every adapter in this system. You are never locked in anywhere β if it has a Port, it can be swapped.
The 3-step recipe:
- Find the Port β Locate the
typing.Protocolinterface insrc/<domain>/core/ports/that defines the contract. - Write a new Adapter β Create a new file in
src/<domain>/adapters/and implement every method the Port requires. - Plug it in β Open
src/<domain>/api/container.pyand swap out the old adapter instantiation for your new one. Nothing else needs to change.
Tip
The Core business logic has zero knowledge of which adapter is plugged in. Swapping is purely a wiring concern confined to container.py β your use cases, routes, and tests don't need to be touched.
Here is every swappable port in the system:
| Port Interface | Located In | What It Controls |
|---|---|---|
SharedEmailClientPort |
src/shared/core/ports/ |
Email delivery provider (Resend, SendGrid, SMTP, etc.) |
CachePort |
src/shared/core/ports/ |
Caching backend (Redis, Memcached, in-memory, etc.) |
TaskRunnerPort |
src/shared/core/ports/ |
Background task engine (asyncio, Celery, RabbitMQ, etc.) |
EmailSenderPort |
src/authentication/core/ports/ |
Domain-specific auth email rendering logic |
AccessTokenPort |
src/authentication/core/ports/security/ |
JWT signing scheme (RS256, HS256, custom, etc.) |
UserRepositoryPort |
src/authentication/core/ports/repository/ |
User storage backend (PostgreSQL, MongoDB, etc.) |
ClaimsProviderPort |
src/authentication/core/ports/ |
Custom JWT claims / authorization rules |
This template uses a dynamic OAuth Registry powered by Authlib. To add a new provider (e.g., Spotify, Discord), you simply create a single file. You do not need to touch any central configuration files or routing logic!
-
Add your provider's credentials to your
.envfile:DISCORD_CLIENT_ID="your_client_id" DISCORD_CLIENT_SECRET="your_client_secret"
-
Create a new file in
src/authentication/infrastructure/oauth/providers/discord.py. -
Use the dynamic
oauth_settingsto grab your credentials, and the@oauth_registry.register_providerdecorator to register the provider:from src.authentication.infrastructure.oauth.registry import oauth_registry from src.authentication.core.domain.user import OAuthUserInfo from src.shared.config import oauth_settings # 1. Fetch credentials dynamically from the .env file! client_id, client_secret = oauth_settings.get_credentials("discord") @oauth_registry.register_provider( "discord", client_id=client_id, client_secret=client_secret, api_base_url="https://discord.com/api/", authorize_url="https://discord.com/api/oauth2/authorize", access_token_url="https://discord.com/api/oauth2/token", client_kwargs={"scope": "identify email"}, ) async def parse_discord_user(provider, token: dict) -> OAuthUserInfo: # 2. Fetch user data from the provider response = await provider.get("users/@me", token=token) data = response.json() # 3. Return a standardized profile for the Core domain return OAuthUserInfo( provider="discord", sub=str(data["id"]), email=data.get("email"), name=data.get("username"), picture=None )
Done! The dynamic routes /auth/login/discord and /auth/callback/discord will automatically start working.
Note
Zero boilerplate required. The oauth/__init__.py uses pkgutil to auto-discover every module inside the providers/ package at startup. You do not need to manually add an import to any __init__.py β simply creating the file is enough.
Because the system is fully modular, removing an unwanted provider is incredibly clean:
- Delete the provider's file (e.g.,
src/authentication/infrastructure/oauth/providers/google.py). - (Optional) Remove the credentials from your
.envfile.
The dynamic routes /auth/login/google and /auth/callback/google will instantly vanish from your API without leaving any dead code behind.
This template natively handles Authentication (identity verification) but leaves Authorization (access control) open so you can implement Role-Based Access Control (RBAC) or Policy-Based Access Control (PBAC).
Because this template uses strict Clean Architecture, domains do not talk to each other directly. Instead, they communicate through Interfaces (Ports).
- The Authentication domain needs to know what roles to inject into a user's JWT when they log in. It defines an interface called
ClaimsProviderPort. - The Authorization domain contains your actual business rules for access control.
- We bridge them using a single concrete class:
CustomAuthorizationAdapter(located insrc/authorization/adapters/custom_authorization.py). - In the Dependency Injection container (
src/authentication/api/container.py), we instantiate this adapter ascustom_claims_providerand inject it into the Authentication system.
To implement your custom RBAC/PBAC rules, edit the CustomAuthorizationAdapter:
# src/authorization/adapters/custom_authorization.py
from uuid import UUID
class CustomAuthorizationAdapter(AuthorizationPort[AsyncSession]):
# 1. Stateless Roles (Injected into JWT)
async def get_custom_claims(self, session: AsyncSession, user_id: UUID) -> dict:
# Example: Fetch user roles from the database
roles = await self._fetch_user_roles(session, user_id)
# These roles are embedded into the Access Token when the user logs in!
return {"roles": roles}
# 2. Stateful Permissions (Live Database Check)
async def has_permission(self, session: AsyncSession, user_id: UUID, action: str, resource: str) -> bool:
# Example: Check if the user owns a specific document
return await self._check_db_for_ownership(session, user_id, action, resource)Now that your rules are defined in the adapter, you can enforce them on any FastAPI route using the built-in dependencies:
from fastapi import APIRouter, Depends
from src.authorization.api.dependencies import require_role, require_permission
router = APIRouter()
# Stateless check: Fast, no DB hit.
# It reads the "roles" array from the JWT (populated by get_custom_claims).
@router.post("/admin/dashboard", dependencies=[Depends(require_role("admin"))])
async def view_dashboard():
pass
# Stateful check: Granular, queries the DB.
# This triggers the has_permission() method in your CustomAuthorizationAdapter.
@router.delete("/documents/{id}", dependencies=[Depends(require_permission("delete", "document"))])
async def delete_document(id: str):
passThis template uses beautifully styled Jinja2 HTML templates for all outbound emails (verification codes, password resets, welcome emails, etc.). These templates are located in src/shared/templates/emails/.
Building HTML emails is notoriously frustrating because you normally have to send an actual email to see what it looks like. We fixed that!
If ENV="development" is set in your .env, we expose a special suite of developer routes that render the email templates directly in your browser.
Simply spin up the backend and navigate to the gallery root:
http://localhost:8000/dev/email/preview
Here you can:
- Browse and preview all templates side-by-side.
- Toggle between aesthetic themes (
Modern,Minimal,Playful). - Test responsiveness with
Desktop,Tablet, andMobilewidth constraints. - Toggle
Dark Modeto see how email clients (like Gmail) will invert your colors.
(Note: These /dev/ routes are strictly disabled when ENV="production").
FastAPI is incredibly fast, but sending emails or writing logs can block the event loop if executed synchronously. This template uses a background task pipeline to ensure APIs return instantly.
The src/shared/adapters/task_runner/asyncio_task_runner.py executes tasks in the background natively. You can queue a task anywhere in your code without needing a heavy Celery worker:
from uuid import UUID
from src.shared.container import shared_container
async def my_slow_function(user_id: UUID) -> None:
pass
# Push it to the background and return immediately
shared_container.task_runner.add_task(my_slow_function, user.id)Warning
Production Scaling: The built-in asyncio_task_runner is incredibly convenient for lightweight tasks, but it stores pending tasks in RAM. If the server crashes, pending tasks are lost. For high-throughput or mission-critical enterprise applications, it is highly recommended to swap this out for a robust message queue/worker architecture. We provide a CeleryTaskRunner out of the box. Open src/shared/container.py and swap it in for production!
The template is highly decoupled, making unit and integration testing incredibly easy. Tests are located in the tests/ directory and use pytest.
To run the entire test suite:
uv run pytest tests/- Core Tests: Located in
tests/core/. These test the pure business logic without spinning up a database or HTTP server. - API Tests: Located in
tests/api/. These spin up an ephemeral SQLite database to test the FastAPI routes end-to-end.
Before deploying this template to a live environment, you must verify the following:
The default MemoryCacheAdapter uses a built-in Python dictionary. In a multi-worker production environment (e.g., gunicorn -w 4), each worker will have an isolated cache. This completely breaks Rate Limiting and per-jti Access Token blacklisting (used on logout). Note that session family revocation is DB-based and works correctly across workers without Redis. Open src/shared/container.py and swap to RedisCacheAdapter before deploying.
Warning
The asyncio.Lock inside MemoryCacheAdapter provides no cross-process protection. It only guards against concurrent access within a single worker process.
Leaving ENV="development" in production exposes the /dev/email/preview gallery routes to the public and disables secure cookie validation.
Ensure CORS_ORIGINS is explicitly defined in your .env (e.g., CORS_ORIGINS="https://myapp.com,https://admin.myapp.com"). Never leave it as a wildcard * in production, as this opens the API up to Cross-Origin attacks.
Because the system relies on an HttpOnly cookie for the Refresh Token, the frontend and backend must either share a domain (e.g., api.example.com and app.example.com) or you must strictly configure your Reverse Proxy/Load Balancer to handle CORS and SameSite=None; Secure cookie attributes properly. Otherwise, the browser will silently block the refresh token cookie.
As noted above, the built-in asyncio_task_runner holds pending tasks in RAM. If the server crashes, pending emails or logs are lost. Swap this out for a robust message queue (Celery/Kafka) if you require guaranteed task execution.