Production-oriented JWT authentication service built with FastAPI, PostgreSQL, and Redis.
Implements secure JWT authentication with access/refresh token rotation, Redis-backed refresh token revocation, Role-Based Access Control (RBAC), and a fully async, layered architecture designed for scalability and maintainability.
- FastAPI app with versioned API prefix:
/api/v1 - JWT authentication:
- Access token in response body
- Refresh token in
HttpOnlycookie
- Refresh token rotation with one-time use semantics via Redis
GETDEL - Role-Based Access Control (RBAC):
- User role embedded into JWT access tokens
- Hierarchical role levels (
user<admin) - Role-based endpoint protection via the
RequireRoledependency
- Password hashing with Argon2 (Passlib)
- Async SQLAlchemy 2.0 + asyncpg
- Alembic migration setup (async)
- Infrastructure lifecycle managed via FastAPI
lifespanandapp.state - Dependency injection with typed CRUD protocols
- Centralized exception handling
- Rate limiting (SlowAPI)
- CORS middleware configuration
- Liveness / readiness health probes
- Automated CI/CD pipelines (GitHub Actions)
- Python 3.12+
- FastAPI
- SQLAlchemy (async)
- PostgreSQL (asyncpg)
- Redis
- Alembic
- PyJWT
- Passlib (argon2)
- Pydantic Settings
- SlowAPI (rate limiting)
- Ruff, Mypy, and Black (linting & formatting)
- Pytest (unit tests)
- Docker & Docker Compose
- GitHub Actions (CI/CD)
The project follows a layered architecture with clear separation of concerns:
API (endpoints) -> Services (business logic) -> CRUD (data access) -> Models
- Endpoints are thin and delegate to services.
- Services contain business logic and depend on CRUD via a
Protocol(app/protocols/user.py), which keeps them decoupled and easy to test. - CRUD performs data access only; transaction commit/rollback is centralized in the database session dependency.
- Infrastructure (database engine, Redis client) is created once on startup
in
app/lifespan.py, stored onapp.state, and accessed through dependencies.
.
|- alembic/
| |- env.py
| |- versions/
|- app/
| |- main.py # App factory, middleware, routers
| |- lifespan.py # Startup/shutdown: db & redis clients on app.state
| |- api/
| | |- health.py # Liveness / readiness probes
| | |- v1/
| | | |- router.py
| | | |- endpoints/ # auth, users
| |- auth/ # Auth dependencies, RBAC permissions, token schema
| |- core/ # Config, security, constants, limiter, logger
| |- crud/ # Data access layer
| |- db/ # DatabaseClient (postgres), RedisClient
| |- dependencies/ # DI: db session, redis, crud, services
| |- enums/ # Role enum with hierarchical levels
| |- exceptions/ # Custom exceptions + handlers
| |- models/ # SQLAlchemy models
| |- protocols/ # Typed CRUD protocols for DI
| |- schemas/ # Pydantic schemas
| |- services/ # Business logic (user, auth)
| |- utils/ # Helpers (cookie handling)
|- tests/
| |- fakes/
| |- unit/
|- .env.template
|- alembic.ini
|- docker-compose.yml # Local development
|- docker-compose.prod.yml # Production
|- Dockerfile
|- Makefile
|- poetry.lock
|- pyproject.toml
|- pytest.ini
|- README.md
- Register a user (
POST /api/v1/register) - Login (
POST /api/v1/login) - Receive:
access_tokenin the JSON responserefresh_tokenin a secureHttpOnlycookie
- Use the access token for protected endpoints (Bearer auth)
- The access token carries authenticated user information, including the assigned role
- Protected endpoints enforce role-based authorization via
RequireRole - Refresh the access token (
POST /api/v1/refresh) using the refresh cookie (the old refresh token is rotated and invalidated) - Logout (
POST /api/v1/logout) revokes the refresh token in Redis and clears the cookie
Auth and user endpoints are under /api/v1. Health endpoints are top-level.
| Method | Path | Auth | Rate Limit | Description |
|---|---|---|---|---|
| GET | /health/live |
No | - | Liveness probe |
| GET | /health/ready |
No | - | Readiness probe (checks Postgres + Redis) |
| POST | /api/v1/register |
No | 1/min | Register a new user |
| POST | /api/v1/login |
No | 5/min | Login with username/password form |
| POST | /api/v1/refresh |
No | 3/min | Rotate refresh token and issue a new access token |
| POST | /api/v1/logout |
No | 3/min | Revoke the current refresh token and clear the cookie |
| GET | /api/v1/about_me |
Bearer (user) |
- | Get the current authenticated user |
Configuration is loaded from a .env file via Pydantic Settings.
| Variable | Required | Notes |
|---|---|---|
DEBUG |
No | true or false (default false) |
CORS_ORIGINS |
No | JSON array of allowed origins |
POSTGRES_DB |
Yes* | Used by Docker Compose to init the database |
POSTGRES_USER |
Yes* | Used by Docker Compose to init the database |
POSTGRES_PASSWORD |
Yes* | Used by Docker Compose to init the database |
DATABASE_URL |
Yes | Must start with postgresql+asyncpg:// or postgres+asyncpg:// |
REDIS_URL |
Yes | Must start with redis:// or rediss:// |
ACCESS_SECRET |
Yes | Min length: 32; must not start with CHANGE_ME |
REFRESH_SECRET |
Yes | Min length: 32; must not start with CHANGE_ME |
ACCESS_TOKEN_EXPIRE_M |
No | Access token lifetime in minutes (default: 15) |
REFRESH_TOKEN_EXPIRE_M |
No | Refresh token lifetime in minutes (default: 43200) |
COOKIE_SECURE |
No | Default: true |
COOKIE_SAMESITE |
No | One of lax, strict, none |
* Required only when running via Docker Compose.
Notes:
- The app validates configuration on startup and refuses to run with missing,
malformed, or default (
CHANGE_ME...) secrets. - For local HTTP development without HTTPS, set
COOKIE_SECURE=false, otherwise the browser/client may not send the refresh cookie and/refresh//logoutcan fail with401.
This is the recommended way to run the project locally. Service hostnames
(db, redis) resolve inside the Docker network.
cp .env.template .envOn Windows PowerShell:
Copy-Item .env.template .envThen edit .env and set real values (secrets, database credentials).
docker compose up --builddocker compose run --rm app alembic upgrade headMakefile shortcuts:
make up
make down
make migrate
make makemigrations m="describe change"
make logsOnce running, open:
- Swagger UI:
http://127.0.0.1:8000/docs - ReDoc:
http://127.0.0.1:8000/redoc
A dedicated compose file is provided for production (e.g., on a VPS). It pulls a
prebuilt image and reads secrets from a server-side .env:
docker compose -f docker-compose.prod.yml up -dDeployment is automated via GitHub Actions (.github/workflows/cd.yml): on a
successful CI run against main, the image is built, pushed to GHCR, migrations
are applied, and the stack is redeployed.
If you prefer running the app directly on your host, you need local PostgreSQL
and Redis instances, and .env hostnames pointing to localhost.
- Python 3.12+
- Poetry 2.0+
- PostgreSQL
- Redis
pipx install poetrypoetry install --with devTip: run poetry shell once, then run commands without the poetry run prefix.
Create .env from the template and point DATABASE_URL / REDIS_URL to
localhost.
poetry run alembic upgrade head
poetry run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000Run all tests:
poetry run pytestRun only unit tests:
poetry run pytest tests/unitFormat code with Black:
poetry run black .Lint code with Ruff:
poetry run ruff check .Check types with Mypy:
poetry run mypy appcurl -X POST "http://127.0.0.1:8000/api/v1/register" \
-H "Content-Type: application/json" \
-d '{"username":"john_doe","email":"john@example.com","password":"StrongPass123"}'curl -i -X POST "http://127.0.0.1:8000/api/v1/login" \
-H "Content-Type: application/x-www-form-urlencoded" \
-c cookies.txt \
-d "username=john_doe&password=StrongPass123"curl -i -X POST "http://127.0.0.1:8000/api/v1/refresh" \
-b cookies.txt \
-c cookies.txtcurl -X GET "http://127.0.0.1:8000/api/v1/about_me" \
-H "Authorization: Bearer <access_token>"curl -i -X POST "http://127.0.0.1:8000/api/v1/logout" \
-b cookies.txt \
-c cookies.txtApplication-specific errors use this shape:
{"detail": "..."}Common statuses:
201Created (register)200OK (login, refresh, logout, about_me)401Unauthorized (invalid credentials/token)403Forbidden (insufficient role)404Not Found (user not found)409Conflict (user already exists)429Too Many Requests (rate limit exceeded)
Create a migration:
poetry run alembic revision --autogenerate -m "describe change"Upgrade the database:
poetry run alembic upgrade headDowngrade one revision:
poetry run alembic downgrade -1