0x74 0x68 0x65 0x72 0x65 0x20 0x61 0x72 0x65 0x20 0x31 0x30 0x20 0x74 0x79 0x70 0x65 0x73 0x20 0x6f 0x66 0x20 0x70 0x65 0x6f 0x70 0x6c 0x65 0x74 0x68 0x6f 0x73 0x65 0x20 0x77 0x68 0x6f 0x20 0x75 0x6e 0x64 0x65 0x72 0x73 0x74 0x61 0x6e 0x64 0x20 0x62 0x69 0x6e 0x61 0x72 0x79 0x20 0x61 0x6e 0x64 0x20 0x74 0x68 0x6f 0x73 0x65 0x20 0x77 0x68 0x6f 0x20 0x64 0x6f 0x6e 0x74
01110100 01101000 01100101 01110010 01100101 00100000 01100001 01110010 01100101 00100000 00110001 00110000 00100000 01110100 01111001 01110000 01100101 01110011 0xdeadbeef 0xcafebabe 0x1337
0x74 0x68 0x6f 0x73 0x65 0x20 0x77 0x68 0x6f 0x20 0x75 0x6e 0x64 0x65 0x72 0x73 0x74 0x61 0x6e 0x64 0x20 0x62 0x69 0x6e 0x61 0x72 0x79 0x20 0x61 0x6e 0x64 0x20 0x74 0x68 0x6f 0x73 0x65 0x20 0x77 0x68 0x6f 0x20 0x64 0x6f 0x6e 0x74 0x74 0x68 0x65 0x72 0x65 0x20 0x61 0x72 0x65 0x20 0x31 0x30 0x20 0x74 0x79 0x70 0x65 0x73 0x20 0x6f 0x66 0x20 0x70 0x65 0x6f 0x70 0x6c 0x65
01010111 01100101 00100000 01101000 01100001 01100011 01101011 00100000 01110100 01101000 01100101 00100000 01110000 01101100 01100001 01101110 01100101 01110100 01100110 00110000 01111000 01100110 00110100 01100100 01100101
0x6e 0x65 0x76 0x65 0x72 0x20 0x67 0x6f 0x6e 0x6e 0x61 0x20 0x64 0x72 0x6f 0x70 0x20 0x79 0x6f 0x75 0x72 0x20 0x73 0x68 0x65 0x6c 0x6c 0x20 0x6e 0x65 0x76 0x65 0x72 0x20 0x67 0x6f 0x6e 0x6e 0x61 0x20 0x6b 0x69 0x6c 0x6c 0x20 0x79 0x6f 0x75 0x72 0x20 0x74 0x68 0x72 0x65 0x61 0x64 0x20 0x6e 0x65 0x76 0x65 0x72 0x20 0x67 0x6f 0x6e 0x6e 0x61 0x20 0x6c 0x6f 0x73 0x65 0x20 0x79 0x6f 0x75 0x72 0x20 0x70 0x61 0x63 0x6b 0x65 0x74 0x20 0x61 0x6e 0x64 0x20 0x64 0x65 0x73 0x65 0x72 0x74 0x20 0x79 0x6f 0x75 0x72 0x20 0x71 0x75 0x65 0x75 0x65
Published on

Authentik SSO with FastAPI

Authentik SSO with FastAPI

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.

KeycloakAuthentik
LanguageJavaPython / Go
Memory footprint512MB to 1GB+200 to 400MB
DatabaseRequires Postgres or MySQLPostgres (bundled in Compose)
Setup complexityHigh (many config screens)Medium (opinionated defaults)
DocumentationExtensiveGood, improving
Enterprise adoptionVery highGrowing
LDAP / AD supportYesYes
SAML supportYesYes
SCIM supportLimitedYes
Flow customizationRole/policy basedFlow editor (visual)
Homelab friendlinessMediumHigh

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.

  1. Go to Admin Interface and click Applications then Providers
  2. Click Create, choose OAuth2/OpenID Provider
  3. Set Name to todo-api-provider
  4. Set Client type to Confidential
  5. Under Redirect URIs, add http://localhost:3000/callback
  6. Set Signing Key to the default certificate
  7. 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.

  1. Click Applications then Applications
  2. Click Create
  3. Set Name to Todo App
  4. Set Slug to todo-app
  5. Set Provider to todo-api-provider
  6. Click Create

Create a Test User

  1. Click Directory then Users
  2. Click Create
  3. Fill in Username, Name, and Email
  4. 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:

  1. FastAPI backend with SQLite and SQLAlchemy: the foundation
  2. React frontend with Vite and TypeScript: the UI
  3. JWT authentication with FastAPI's built-in OAuth2: rolling your own auth and understanding the tradeoffs
  4. Docker Compose deployment: containerizing and deploying the stack
  5. Docker vs Podman for homelab scenarios: picking the right container runtime
  6. Keycloak SSO: the enterprise-grade identity provider
  7. 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.