- Published on
Keycloak SSO with FastAPI


- Name
- Chad Wilson
- @NetPenguins
Series
Full Stack Todo App
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:
- The user logs in through Keycloak's login page
- Keycloak issues a JWT signed with its own private key
- The frontend passes that token to our API
- 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.
- Click the realm dropdown (top left, shows "Keycloak")
- Click Create realm
- Set Realm name to
todo-app - Click Create
Create the API Client
This client represents your FastAPI backend as a resource server.
- Click Clients in the left sidebar
- Click Create client
- Set Client ID to
todo-api - Set Client type to
OpenID Connect - Click Next
- Disable Standard flow and Direct access grants
- Enable Service accounts roles
- 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.
- Click Clients then Create client
- Set Client ID to
todo-ui - Click Next
- Enable Standard flow, disable everything else
- Click Next
- Set Valid redirect URIs to
http://localhost:3000/* - Set Web origins to
http://localhost:3000 - Click Save
On the Settings tab, enable PKCE by setting Proof Key for Code Exchange Code Challenge Method to S256.
Create a Test User
- Click Users in the left sidebar
- Click Create new user
- Set Username to
testuser - Click Create
- Go to the Credentials tab
- Click Set password, enter a password, disable Temporary
- 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.