- Published on
Authentik SSO with FastAPI


- Name
- Chad Wilson
- @NetPenguins
Series
Full Stack Todo App
Welcome back! In the previous post we set up Keycloak as an SSO provider for our todo app. Keycloak is solid but it is also a Java application that wants a meaningful chunk of RAM and a separate database to run properly. For a homelab or self-hosted setup with limited resources, that can be a real constraint.
Authentik is a Python-based identity provider that covers most of the same ground: OAuth2, OIDC, SAML, LDAP, SCIM, social login, MFA, and policy-based access control. It is actively maintained, has a clean modern UI, and uses significantly less memory than Keycloak. It has become a popular choice in the homelab community for exactly these reasons.
This post walks through standing up Authentik, configuring an application for our todo app, and wiring the FastAPI backend to validate Authentik-issued tokens.
Authentik vs Keycloak at a Glance
Before getting into the setup, here is an honest comparison.
| Keycloak | Authentik | |
|---|---|---|
| Language | Java | Python / Go |
| Memory footprint | 512MB to 1GB+ | 200 to 400MB |
| Database | Requires Postgres or MySQL | Postgres (bundled in Compose) |
| Setup complexity | High (many config screens) | Medium (opinionated defaults) |
| Documentation | Extensive | Good, improving |
| Enterprise adoption | Very high | Growing |
| LDAP / AD support | Yes | Yes |
| SAML support | Yes | Yes |
| SCIM support | Limited | Yes |
| Flow customization | Role/policy based | Flow editor (visual) |
| Homelab friendliness | Medium | High |
Neither is objectively better. Keycloak wins on enterprise pedigree and ecosystem integrations. Authentik wins on resource efficiency and ease of initial setup. Running a homelab on a small VM or a mini PC with 4GB RAM? Authentik is the easier choice.
Standing Up Authentik with Docker Compose
Authentik provides an official Docker Compose file. Pull it first:
wget https://goauthentik.io/docker-compose.yml
Before starting, generate the required secrets:
echo "PG_PASS=$(openssl rand -base64 36 | tr -d '\n')" >> .env
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60 | tr -d '\n')" >> .env
Then add the Authentik services to your project's docker-compose.yml, or run the Authentik Compose separately if you prefer to keep it isolated. For homelab use, running it on a shared machine and pointing multiple apps at it is a common pattern.
The official Compose file runs four services:
- authentik-server: the main application (handles UI and API)
- authentik-worker: background job processor
- postgresql: Authentik's database
- redis: cache and task queue
Start it:
docker compose up -d
On first boot, navigate to http://localhost:9000/if/flow/initial-setup/ to create your admin account. After that, the admin interface is at http://localhost:9000/if/admin/.
Configuring Authentik
Create a Provider
In Authentik, a Provider defines how an application authenticates. We will use OAuth2/OpenID Connect.
- Go to Admin Interface and click Applications then Providers
- Click Create, choose OAuth2/OpenID Provider
- Set Name to
todo-api-provider - Set Client type to
Confidential - Under Redirect URIs, add
http://localhost:3000/callback - Set Signing Key to the default certificate
- Click Finish
After creation, click into the provider and note the Client ID and Client Secret. Add them to your .env:
AUTHENTIK_SERVER_URL=http://localhost:9000
AUTHENTIK_CLIENT_ID=your_client_id
AUTHENTIK_CLIENT_SECRET=your_client_secret
Create an Application
In Authentik, a Provider handles the auth protocol and an Application is the thing users see. They are linked together.
- Click Applications then Applications
- Click Create
- Set Name to
Todo App - Set Slug to
todo-app - Set Provider to
todo-api-provider - Click Create
Create a Test User
- Click Directory then Users
- Click Create
- Fill in Username, Name, and Email
- After creating, click into the user and set a password under Update password
Updating FastAPI
The FastAPI side looks similar to the Keycloak version. The main difference is that Authentik uses RS256 (asymmetric signing), so we verify tokens locally using Authentik's public keys instead of making an introspection call on every request.
Install the dependencies:
pip install PyJWT cryptography requests
Update requirements.txt:
PyJWT[cryptography]
requests
fastapi
uvicorn
sqlalchemy
python-dotenv
alembic
Fetching Authentik's Public Keys
Authentik publishes its public keys at a JWKS (JSON Web Key Set) endpoint. We fetch these at startup and use them to verify tokens locally.
# auth/dependencies.py
from os import getenv
import jwt
from jwt import PyJWKClient
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2AuthorizationCodeBearer
AUTHENTIK_SERVER_URL = getenv("AUTHENTIK_SERVER_URL", "http://localhost:9000")
AUTHENTIK_CLIENT_ID = getenv("AUTHENTIK_CLIENT_ID")
JWKS_URI = f"{AUTHENTIK_SERVER_URL}/application/o/todo-app/jwks/"
ISSUER = f"{AUTHENTIK_SERVER_URL}/application/o/todo-app/"
jwks_client = PyJWKClient(JWKS_URI)
oauth2_scheme = OAuth2AuthorizationCodeBearer(
authorizationUrl=f"{AUTHENTIK_SERVER_URL}/application/o/authorize/",
tokenUrl=f"{AUTHENTIK_SERVER_URL}/application/o/token/",
)
def get_current_user(token: str = Depends(oauth2_scheme)) -> dict:
try:
signing_key = jwks_client.get_signing_key_from_jwt(token)
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience=AUTHENTIK_CLIENT_ID,
issuer=ISSUER,
)
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired",
headers={"WWW-Authenticate": "Bearer"},
)
except jwt.InvalidTokenError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
PyJWKClient fetches the JWKS endpoint and caches the keys. It handles key rotation automatically: when the key ID in the token header does not match a cached key, it refetches. Local verification is fast and does not create a dependency on Authentik being reachable for every API request.
Routes
The routes are identical to the Keycloak version. The current_user dict contains the decoded JWT claims, and sub is Authentik's user ID.
@router.get("/", response_model=List[TodoResponse])
def get_todos(
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
user_id = current_user["sub"]
return db.query(Todo).filter(Todo.user_id == user_id).all()
This is one of the benefits of building around the standard JWT sub claim. Swapping identity providers requires no route changes, only the verification logic in auth/dependencies.py.
Updating the React Frontend
Authentik supports the Authorization Code flow with PKCE. The frontend flow is nearly identical to the Keycloak setup, but using a generic OIDC library instead of the Keycloak-specific adapter.
Install oidc-client-ts:
npm install oidc-client-ts
Create src/auth.ts:
import { UserManager, WebStorageStateStore } from 'oidc-client-ts'
const AUTHENTIK_URL = import.meta.env.VITE_AUTHENTIK_URL ?? 'http://localhost:9000'
const APP_SLUG = import.meta.env.VITE_AUTHENTIK_APP_SLUG ?? 'todo-app'
const CLIENT_ID = import.meta.env.VITE_AUTHENTIK_CLIENT_ID ?? ''
export const userManager = new UserManager({
authority: `${AUTHENTIK_URL}/application/o/${APP_SLUG}/`,
client_id: CLIENT_ID,
redirect_uri: `${window.location.origin}/callback`,
response_type: 'code',
scope: 'openid profile email',
userStore: new WebStorageStateStore({ store: window.localStorage }),
})
export const login = () => userManager.signinRedirect()
export const logout = () => userManager.signoutRedirect()
export const handleCallback = () => userManager.signinRedirectCallback()
export const getUser = () => userManager.getUser()
Update src/main.tsx to handle the callback and protect the app:
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import { handleCallback, getUser, login } from './auth'
async function bootstrap() {
if (window.location.pathname === '/callback') {
await handleCallback()
window.history.replaceState({}, '', '/')
}
const user = await getUser()
if (!user || user.expired) {
await login()
return
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
}
bootstrap()
Update src/services/ApiService.ts to pull the token from oidc-client-ts:
import axios from 'axios'
import { getUser } from '../auth'
import { CreateTodoPayload, Todo, UpdateTodoPayload } from '../types'
const API_BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'
const client = axios.create({ baseURL: API_BASE_URL })
client.interceptors.request.use(async (config) => {
const user = await getUser()
if (user?.access_token) {
config.headers.Authorization = `Bearer ${user.access_token}`
}
return config
})
export const getAllTodos = async (): Promise<Todo[]> => {
const response = await client.get<Todo[]>('/todos/')
return response.data
}
export const createTodo = async (payload: CreateTodoPayload): Promise<Todo> => {
const response = await client.post<Todo>('/todos/', payload)
return response.data
}
export const updateTodo = async (id: string, payload: UpdateTodoPayload): Promise<Todo> => {
const response = await client.put<Todo>(`/todos/${id}`, payload)
return response.data
}
export const deleteTodo = async (id: string): Promise<void> => {
await client.delete(`/todos/${id}`)
}
Add the Authentik environment variables to .env:
VITE_AUTHENTIK_URL=http://localhost:9000
VITE_AUTHENTIK_APP_SLUG=todo-app
VITE_AUTHENTIK_CLIENT_ID=your_client_id
Authentik's Flow Editor
One thing worth highlighting that is unique to Authentik is its visual flow editor. Every authentication or enrollment sequence is a configurable flow: a series of stages the user moves through. You can build and modify these flows in the admin UI.
Want to add MFA to the login flow? Drag in an Authenticator Validation Stage. Want a custom password policy during enrollment? Add a Password Stage with policy bindings. Need to prompt users to accept terms of service? There is a stage for that.
This is more approachable than Keycloak's authentication flow configuration, which involves clicking through nested tabs. The visual approach in Authentik makes it usable for homelab operators who are not identity platform specialists.
Production Notes for Authentik
PostgreSQL is required. In production you want a managed Postgres instance with backups. Authentik stores users, sessions, tokens, and configuration in Postgres. Losing it means losing all your users.
Redis is required. Authentik uses Redis for session storage and the task queue. It does not run without it.
HTTPS. Same as Keycloak: everything needs HTTPS in production. The OIDC redirect_uri registered in Authentik must exactly match the callback URL the browser hits, including the scheme. HTTP works for local development only.
Backups. Your Postgres database and the authentik_media volume (custom branding, uploaded certificates) are the two things to back up. Everything else can be reconfigured.
Outposts. Authentik has a concept called outposts: external components that handle specific protocols. The Proxy outpost lets Authentik act as a forward auth layer in front of apps that do not natively support OIDC. For our FastAPI app this is unnecessary since we are doing native OIDC integration, but it is useful for other services in a homelab that you want to put behind auth without modifying their code.
Wrapping Up the Series
We have covered a lot of ground across this series:
- FastAPI backend with SQLite and SQLAlchemy: the foundation
- React frontend with Vite and TypeScript: the UI
- JWT authentication with FastAPI's built-in OAuth2: rolling your own auth and understanding the tradeoffs
- Docker Compose deployment: containerizing and deploying the stack
- Docker vs Podman for homelab scenarios: picking the right container runtime
- Keycloak SSO: the enterprise-grade identity provider
- Authentik SSO: the homelab-friendly alternative
The todo app is intentionally simple, but every concept here scales. SQLAlchemy with SQLite becomes SQLAlchemy with PostgreSQL. FastAPI's JWT auth becomes Keycloak or Authentik. Docker Compose on one box becomes Kubernetes with a few config changes. The patterns transfer regardless of scale.
Thanks for following along.