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

Keycloak SSO with FastAPI

Keycloak SSO with FastAPI

Welcome back! In the auth post we built JWT authentication from scratch using FastAPI, passlib, and python-jose. That setup works and taught us how tokens function under the hood. But it also leaves a lot on our plate: password resets, account lockout, MFA, session management. At some point the right answer is to hand that responsibility to something built for it.

Keycloak is the most feature-complete self-hostable identity provider. It is open source, maintained by Red Hat, and widely used in enterprise environments. If you need LDAP/AD integration, fine-grained authorization policies, or a battle-tested system with years of production use behind it, Keycloak is the one to reach for.

This post walks through standing up Keycloak locally, configuring it for our todo app, and updating FastAPI to validate tokens that Keycloak issues rather than tokens we generate ourselves.


What Changes

When using an external identity provider, our API no longer issues tokens. Instead:

  1. The user logs in through Keycloak's login page
  2. Keycloak issues a JWT signed with its own private key
  3. The frontend passes that token to our API
  4. Our API validates the token by checking the signature against Keycloak's public key (fetched from Keycloak's JWKS endpoint)

Our API stops being the authority on who is authenticated and becomes a resource server. It only validates tokens and extracts claims. The auth/ router and User database table we built in the previous post can be removed. In this post we will replace the auth layer entirely.


Standing Up Keycloak with Docker

Add a Keycloak service to docker-compose.yml. For development we use start-dev mode, which skips TLS and stores data in an embedded H2 database. Never use this for production.

services:
  keycloak:
    image: quay.io/keycloak/keycloak:23.0
    command: start-dev
    environment:
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin
    ports:
      - "8080:8080"
    networks:
      - todo-net

Start it:

docker compose up keycloak -d

Give it 30 to 60 seconds to initialize, then open http://localhost:8080/admin and log in with admin:admin.


Configuring Keycloak

Create a Realm

A realm in Keycloak is an isolated tenant. The default master realm is for administering Keycloak itself. Create a new one for your app.

  1. Click the realm dropdown (top left, shows "Keycloak")
  2. Click Create realm
  3. Set Realm name to todo-app
  4. Click Create

Create the API Client

This client represents your FastAPI backend as a resource server.

  1. Click Clients in the left sidebar
  2. Click Create client
  3. Set Client ID to todo-api
  4. Set Client type to OpenID Connect
  5. Click Next
  6. Disable Standard flow and Direct access grants
  7. Enable Service accounts roles
  8. Click Save

On the client page, go to the Credentials tab and copy the Client Secret. Add it to your .env:

KEYCLOAK_CLIENT_ID=todo-api
KEYCLOAK_CLIENT_SECRET=your_client_secret_here
KEYCLOAK_SERVER_URL=http://localhost:8080
KEYCLOAK_REALM=todo-app

Create the Frontend Client

This client represents your React app. The frontend uses the Authorization Code flow with PKCE, which is the correct flow for browser-based apps.

  1. Click Clients then Create client
  2. Set Client ID to todo-ui
  3. Click Next
  4. Enable Standard flow, disable everything else
  5. Click Next
  6. Set Valid redirect URIs to http://localhost:3000/*
  7. Set Web origins to http://localhost:3000
  8. Click Save

On the Settings tab, enable PKCE by setting Proof Key for Code Exchange Code Challenge Method to S256.

Create a Test User

  1. Click Users in the left sidebar
  2. Click Create new user
  3. Set Username to testuser
  4. Click Create
  5. Go to the Credentials tab
  6. Click Set password, enter a password, disable Temporary
  7. Click Save

Updating FastAPI

Install the dependency:

pip install python-keycloak

Update requirements.txt:

python-keycloak
fastapi
uvicorn
sqlalchemy
python-dotenv
alembic

New Auth Dependency

Replace auth/dependencies.py with a version that validates against Keycloak:

from os import getenv
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2AuthorizationCodeBearer
from keycloak import KeycloakOpenID
from keycloak.exceptions import KeycloakInvalidTokenError

KEYCLOAK_SERVER_URL = getenv("KEYCLOAK_SERVER_URL", "http://localhost:8080")
KEYCLOAK_REALM = getenv("KEYCLOAK_REALM", "todo-app")
KEYCLOAK_CLIENT_ID = getenv("KEYCLOAK_CLIENT_ID", "todo-api")
KEYCLOAK_CLIENT_SECRET = getenv("KEYCLOAK_CLIENT_SECRET")

keycloak_openid = KeycloakOpenID(
    server_url=f"{KEYCLOAK_SERVER_URL}/",
    realm_name=KEYCLOAK_REALM,
    client_id=KEYCLOAK_CLIENT_ID,
    client_secret_key=KEYCLOAK_CLIENT_SECRET,
)

oauth2_scheme = OAuth2AuthorizationCodeBearer(
    authorizationUrl=f"{KEYCLOAK_SERVER_URL}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/auth",
    tokenUrl=f"{KEYCLOAK_SERVER_URL}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/token",
)


def get_current_user(token: str = Depends(oauth2_scheme)) -> dict:
    try:
        token_info = keycloak_openid.introspect(token)
        if not token_info.get("active"):
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Token is not active",
                headers={"WWW-Authenticate": "Bearer"},
            )
        return token_info
    except KeycloakInvalidTokenError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Could not validate credentials",
            headers={"WWW-Authenticate": "Bearer"},
        )

Token introspection vs local verification. We are calling keycloak_openid.introspect(token), which makes an HTTP request to Keycloak on every authenticated API call. This is simpler to set up and always reflects the current state of the token, including revocations. The downside is latency and a hard dependency on Keycloak being reachable for every request.

The alternative is local verification: fetching Keycloak's public key from its JWKS endpoint and verifying the JWT signature locally without a network call. This is faster and more resilient, but revoked tokens stay valid until they expire. For most use cases introspection is fine to start with. python-keycloak supports both.

Updated Routes

The routes need minimal changes. The current_user is now a dict from the token introspection response rather than a SQLAlchemy model. The sub claim in the token is Keycloak's internal user ID.

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from database import get_db
from models import Todo
from schemas import TodoCreate, TodoUpdate, TodoResponse
from auth.dependencies import get_current_user
from typing import List

router = APIRouter(prefix="/todos", tags=["todos"])


@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()


@router.post("/", response_model=TodoResponse, status_code=status.HTTP_201_CREATED)
def create_todo(
    payload: TodoCreate,
    db: Session = Depends(get_db),
    current_user: dict = Depends(get_current_user),
):
    user_id = current_user["sub"]
    todo = Todo(**payload.model_dump(), user_id=user_id)
    db.add(todo)
    db.commit()
    db.refresh(todo)
    return todo


@router.put("/{todo_id}", response_model=TodoResponse)
def update_todo(
    todo_id: str,
    payload: TodoUpdate,
    db: Session = Depends(get_db),
    current_user: dict = Depends(get_current_user),
):
    user_id = current_user["sub"]
    todo = db.query(Todo).filter(Todo.id == todo_id, Todo.user_id == user_id).first()
    if not todo:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found")
    for field, value in payload.model_dump(exclude_unset=True).items():
        setattr(todo, field, value)
    db.commit()
    db.refresh(todo)
    return todo


@router.delete("/{todo_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_todo(
    todo_id: str,
    db: Session = Depends(get_db),
    current_user: dict = Depends(get_current_user),
):
    user_id = current_user["sub"]
    todo = db.query(Todo).filter(Todo.id == todo_id, Todo.user_id == user_id).first()
    if not todo:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found")
    db.delete(todo)
    db.commit()

Updated main.py

Remove the auth router since Keycloak handles auth endpoints now. The FastAPI app only needs the todos router.

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from database import engine
from models import Base
from routes import router as todos_router

Base.metadata.create_all(bind=engine)

app = FastAPI(title="Todo API")

origins = ["http://localhost:3000"]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

app.include_router(todos_router)

Updating the React Frontend

The frontend needs to redirect the user to Keycloak's login page and handle the auth code callback. The easiest way to do this is with keycloak-js, the official Keycloak JavaScript adapter.

npm install keycloak-js

Create src/keycloak.ts:

import Keycloak from 'keycloak-js'

const keycloak = new Keycloak({
  url: import.meta.env.VITE_KEYCLOAK_URL ?? 'http://localhost:8080',
  realm: import.meta.env.VITE_KEYCLOAK_REALM ?? 'todo-app',
  clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID ?? 'todo-ui',
})

export default keycloak

Update src/main.tsx to initialize Keycloak before rendering the app:

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import keycloak from './keycloak'

keycloak.init({ onLoad: 'login-required', pkceMethod: 'S256' }).then((authenticated) => {
  if (!authenticated) {
    keycloak.login()
    return
  }

  ReactDOM.createRoot(document.getElementById('root')!).render(
    <React.StrictMode>
      <App />
    </React.StrictMode>
  )
})

onLoad: 'login-required' means unauthenticated users are immediately redirected to Keycloak's login page. pkceMethod: 'S256' enables PKCE, which we configured on the client.

Update src/services/ApiService.ts to pull the token from Keycloak rather than localStorage:

import axios from 'axios'
import keycloak from '../keycloak'
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) => {
  await keycloak.updateToken(30)
  if (keycloak.token) {
    config.headers.Authorization = `Bearer ${keycloak.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}`)
}

keycloak.updateToken(30) silently refreshes the access token if it expires within 30 seconds. This handles long sessions without the user needing to log in again.

Add the Keycloak environment variables to .env:

VITE_KEYCLOAK_URL=http://localhost:8080
VITE_KEYCLOAK_REALM=todo-app
VITE_KEYCLOAK_CLIENT_ID=todo-ui

What You Get for Free

Switching to Keycloak means you stop maintaining:

  • Password hashing and storage
  • Token issuance and signing key management
  • Session management
  • Account lockout and brute force protection

And you get access to things you would otherwise have to build:

  • MFA (TOTP, WebAuthn): configurable per realm in the admin UI
  • Social login: Google, GitHub, and others via the Identity Providers section
  • LDAP / Active Directory integration: for corporate environments where users already exist in a directory
  • Fine-grained authorization: role-based and attribute-based access control at the policy level
  • User self-service: password reset and account management at /realms/todo-app/account
  • Audit logging: every login attempt and session is logged

Keycloak in Production

Persistent storage. The start-dev command uses H2, an in-memory database. For production you need PostgreSQL. Add it to your Docker Compose:

keycloak:
  image: quay.io/keycloak/keycloak:23.0
  command: start
  environment:
    KC_DB: postgres
    KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak
    KC_DB_USERNAME: ${KC_DB_USER}
    KC_DB_PASSWORD: ${KC_DB_PASSWORD}
    KC_HOSTNAME: your.domain.com
    KEYCLOAK_ADMIN: admin
    KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
  depends_on:
    - keycloak-db

keycloak-db:
  image: postgres:15-alpine
  environment:
    POSTGRES_DB: keycloak
    POSTGRES_USER: ${KC_DB_USER}
    POSTGRES_PASSWORD: ${KC_DB_PASSWORD}
  volumes:
    - keycloak_data:/var/lib/postgresql/data

TLS. start mode requires HTTPS. Put Keycloak behind a reverse proxy that handles TLS termination, or configure KC_HTTPS_CERTIFICATE_FILE and KC_HTTPS_CERTIFICATE_KEY_FILE.

Resource requirements. Keycloak is a Java application. Expect at least 512MB of RAM, more like 1GB for a responsive instance. On a resource-constrained homelab this matters. That is exactly the conversation we have in the next post.


Up Next

Keycloak is powerful but heavy. In the next post we look at Authentik, a Python-based SSO provider that is significantly easier to self-host, uses less memory, and comes with a modern UI. We will also compare the two so you can pick the right one for your situation.