FastAPI Authentication with Keycloak: Securing Python APIs

Guilliano Molaire Guilliano Molaire 10 min read

Last updated: March 2026

FastAPI is one of the fastest-growing Python web frameworks, and for good reason — it combines high performance with automatic OpenAPI documentation and a dependency injection system that makes auth middleware elegant to implement. Pair it with Keycloak for single sign-on and identity management, and you have a production-ready API authentication stack that doesn’t require a SaaS subscription.

This guide walks through securing a FastAPI application with Keycloak, covering JWT validation with public keys, role-based access control, token introspection, and CORS configuration. Every code example is production-oriented and uses current library versions.

Prerequisites

Step 1: Set Up a Keycloak Client

In the Keycloak Admin Console:

  1. Go to Clients > Create client
  2. Set Client ID to fastapi-app
  3. Set Client type to OpenID Connect
  4. Enable Client authentication (confidential client)
  5. Set Valid redirect URIs to http://localhost:8000/*
  6. Set Web origins to http://localhost:8000

After saving, copy the Client secret from the Credentials tab.

Create Roles

For this tutorial, create two client roles:

  1. Go to your client’s Roles tab
  2. Create api-read and api-admin roles
  3. Assign them to test users under Users > Role mappings > Client roles

To learn more about configuring role-based access, read our RBAC feature overview or our detailed guide on fine-grained authorization in Keycloak.

Step 2: Install Dependencies

Create a requirements.txt:

fastapi>=0.115.0
uvicorn[standard]>=0.34.0
PyJWT[crypto]>=2.9.0
httpx>=0.28.0
pydantic>=2.10.0
pydantic-settings>=2.7.0
python-dotenv>=1.0.0

Install everything:

pip install -r requirements.txt

A note on JWT libraries: we use PyJWT rather than python-jose. PyJWT is actively maintained and widely adopted, whereas python-jose has received very few updates in recent years. PyJWT with the [crypto] extra provides the cryptography backend needed for RS256 signature verification.

Step 3: Configuration with Pydantic Settings

Create a config.py that loads Keycloak settings from environment variables:

# config.py
from pydantic_settings import BaseSettings
from functools import lru_cache

class Settings(BaseSettings):
    keycloak_url: str = "http://localhost:8080"
    keycloak_realm: str = "your-realm"
    keycloak_client_id: str = "fastapi-app"
    keycloak_client_secret: str = ""

    @property
    def issuer(self) -> str:
        return f"{self.keycloak_url}/realms/{self.keycloak_realm}"

    @property
    def jwks_url(self) -> str:
        return f"{self.issuer}/protocol/openid-connect/certs"

    @property
    def token_url(self) -> str:
        return f"{self.issuer}/protocol/openid-connect/token"

    @property
    def introspection_url(self) -> str:
        return f"{self.issuer}/protocol/openid-connect/token/introspect"

    model_config = {"env_file": ".env", "env_prefix": ""}

@lru_cache
def get_settings() -> Settings:
    return Settings()

Create a .env file:

KEYCLOAK_URL=http://localhost:8080
KEYCLOAK_REALM=your-realm
KEYCLOAK_CLIENT_ID=fastapi-app
KEYCLOAK_CLIENT_SECRET=your-client-secret

The @lru_cache ensures settings are loaded once and reused. The computed properties derive OIDC endpoint URLs from the base Keycloak URL so you only need to configure two values.

Step 4: Build the JWT Validation Layer

This is the core of the authentication system. We fetch Keycloak’s public keys via the JWKS endpoint and use them to validate access tokens locally — no round-trip to Keycloak per request:

# auth.py
import jwt
from jwt import PyJWKClient
from fastapi import Depends, HTTPException, Security, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel
from typing import Optional
from config import Settings, get_settings

security = HTTPBearer()

class TokenPayload(BaseModel):
    """Parsed and validated token claims."""
    sub: str
    email: Optional[str] = None
    preferred_username: Optional[str] = None
    realm_roles: list[str] = []
    client_roles: list[str] = []
    scope: str = ""

class JWKSKeyManager:
    """Manages JWKS key fetching with caching."""

    def __init__(self):
        self._clients: dict[str, PyJWKClient] = {}

    def get_client(self, jwks_url: str) -> PyJWKClient:
        if jwks_url not in self._clients:
            self._clients[jwks_url] = PyJWKClient(
                jwks_url,
                cache_keys=True,
                lifespan=3600,  # Cache keys for 1 hour
            )
        return self._clients[jwks_url]

key_manager = JWKSKeyManager()

def decode_token(
    token: str,
    settings: Settings,
) -> TokenPayload:
    """Decode and validate a Keycloak JWT access token."""
    try:
        jwks_client = key_manager.get_client(settings.jwks_url)
        signing_key = jwks_client.get_signing_key_from_jwt(token)

        payload = jwt.decode(
            token,
            signing_key.key,
            algorithms=["RS256"],
            audience="account",
            issuer=settings.issuer,
            options={
                "verify_exp": True,
                "verify_aud": True,
                "verify_iss": True,
            },
        )
    except jwt.ExpiredSignatureError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Token has expired",
        )
    except jwt.InvalidAudienceError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid token audience",
        )
    except jwt.PyJWTError as e:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail=f"Token validation failed: {str(e)}",
        )

    # Extract roles from Keycloak's token structure
    realm_roles = payload.get("realm_access", {}).get("roles", [])
    client_roles = (
        payload.get("resource_access", {})
        .get(settings.keycloak_client_id, {})
        .get("roles", [])
    )

    return TokenPayload(
        sub=payload["sub"],
        email=payload.get("email"),
        preferred_username=payload.get("preferred_username"),
        realm_roles=realm_roles,
        client_roles=client_roles,
        scope=payload.get("scope", ""),
    )

async def get_current_user(
    credentials: HTTPAuthorizationCredentials = Security(security),
    settings: Settings = Depends(get_settings),
) -> TokenPayload:
    """FastAPI dependency that extracts and validates the current user."""
    return decode_token(credentials.credentials, settings)

Key implementation details:

  • JWKS caching: The PyJWKClient caches public keys for one hour, so your API doesn’t fetch keys from Keycloak on every single request. Keys are refreshed automatically when a token uses an unknown key ID (which happens during key rotation).
  • Local validation: The token is validated entirely on your API server. This is faster and more resilient than calling Keycloak’s introspection endpoint on every request.
  • Role extraction: Keycloak embeds roles in a nested structure within the JWT. We extract both realm-level roles and client-specific roles.

You can inspect the structure of your Keycloak tokens using our JWT Token Analyzer to verify the claims being sent.

Step 5: Create Role-Based Dependencies

Build reusable FastAPI dependencies for role checks:

# dependencies.py
from functools import wraps
from fastapi import Depends, HTTPException, status
from auth import TokenPayload, get_current_user

def require_roles(*required_roles: str):
    """Dependency factory that checks for required roles."""
    async def role_checker(
        user: TokenPayload = Depends(get_current_user),
    ) -> TokenPayload:
        all_roles = set(user.realm_roles + user.client_roles)
        missing = set(required_roles) - all_roles
        if missing:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f"Missing required roles: {', '.join(missing)}",
            )
        return user
    return role_checker

def require_any_role(*allowed_roles: str):
    """Dependency factory that checks if user has any of the allowed roles."""
    async def role_checker(
        user: TokenPayload = Depends(get_current_user),
    ) -> TokenPayload:
        all_roles = set(user.realm_roles + user.client_roles)
        if not all_roles.intersection(allowed_roles):
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail="Insufficient permissions",
            )
        return user
    return role_checker

# Convenience dependencies
require_admin = require_roles("api-admin")
require_read = require_any_role("api-read", "api-admin")

This pattern leverages FastAPI’s dependency injection system cleanly. require_roles demands the user have all specified roles; require_any_role demands they have at least one.

Step 6: Token Introspection Endpoint

While local JWT validation is preferred for performance, sometimes you need to check whether a token has been revoked. Keycloak’s token introspection endpoint answers this:

# introspection.py
import httpx
from fastapi import Depends, HTTPException, status
from config import Settings, get_settings

async def introspect_token(
    token: str,
    settings: Settings,
) -> dict:
    """Check if a token is still active via Keycloak's introspection endpoint."""
    async with httpx.AsyncClient() as client:
        response = await client.post(
            settings.introspection_url,
            data={
                "token": token,
                "client_id": settings.keycloak_client_id,
                "client_secret": settings.keycloak_client_secret,
                "token_type_hint": "access_token",
            },
        )

    if response.status_code != 200:
        raise HTTPException(
            status_code=status.HTTP_502_BAD_GATEWAY,
            detail="Failed to reach Keycloak introspection endpoint",
        )

    result = response.json()
    if not result.get("active"):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Token is no longer active",
        )

    return result

Use introspection sparingly — for sensitive operations like financial transactions or admin actions where you need real-time revocation checks. For general API traffic, local JWT validation is sufficient and far more performant.

For more on token lifecycle patterns, see our post on JWT token lifecycle management.

Step 7: Build the FastAPI Application

Bring everything together in your main application:

# main.py
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, Security
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel

from auth import TokenPayload, get_current_user, security
from config import get_settings
from dependencies import require_admin, require_read
from introspection import introspect_token

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Warm up: pre-fetch JWKS keys on startup
    settings = get_settings()
    from auth import key_manager
    client = key_manager.get_client(settings.jwks_url)
    # Force an initial key fetch
    try:
        client.get_jwk_set()
    except Exception:
        print("Warning: Could not pre-fetch JWKS keys")
    yield

app = FastAPI(
    title="FastAPI + Keycloak Example",
    version="1.0.0",
    lifespan=lifespan,
)

# CORS configuration
app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "http://localhost:3000",    # React/Next.js frontend
        "http://localhost:5173",    # Vite dev server
    ],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# --- Response Models ---

class UserInfo(BaseModel):
    sub: str
    email: str | None
    username: str | None
    realm_roles: list[str]
    client_roles: list[str]

class Item(BaseModel):
    id: int
    name: str
    owner: str

# --- Public Endpoints ---

@app.get("/health")
async def health_check():
    return {"status": "healthy"}

# --- Protected Endpoints ---

@app.get("/me", response_model=UserInfo)
async def get_current_user_info(
    user: TokenPayload = Depends(get_current_user),
):
    """Return the authenticated user's information."""
    return UserInfo(
        sub=user.sub,
        email=user.email,
        username=user.preferred_username,
        realm_roles=user.realm_roles,
        client_roles=user.client_roles,
    )

@app.get("/items", response_model=list[Item])
async def list_items(
    user: TokenPayload = Depends(require_read),
):
    """List items. Requires 'api-read' or 'api-admin' role."""
    return [
        Item(id=1, name="Widget", owner=user.sub),
        Item(id=2, name="Gadget", owner=user.sub),
    ]

@app.post("/items", response_model=Item, status_code=201)
async def create_item(
    item_name: str,
    user: TokenPayload = Depends(require_admin),
):
    """Create an item. Requires 'api-admin' role."""
    return Item(id=3, name=item_name, owner=user.sub)

@app.delete("/items/{item_id}", status_code=204)
async def delete_item(
    item_id: int,
    user: TokenPayload = Depends(require_admin),
    credentials: HTTPAuthorizationCredentials = Security(security),
):
    """
    Delete an item.
    Requires 'api-admin' role AND active token (introspection check).
    """
    settings = get_settings()
    await introspect_token(credentials.credentials, settings)
    # Deletion logic here
    return None

# --- Admin-Only Endpoints ---

@app.get("/admin/users")
async def list_users(
    user: TokenPayload = Depends(require_admin),
):
    """Admin endpoint to list users."""
    return {
        "message": "Admin access granted",
        "admin_user": user.preferred_username,
    }

CORS Configuration

Getting CORS right is critical when your frontend and API are on different origins. The configuration above explicitly lists allowed origins. In production, restrict this to your actual frontend domains. For a detailed walkthrough of CORS settings with Keycloak, see our guide on configuring CORS with your Keycloak OIDC client.

Step 8: Run and Test

Start your API:

uvicorn main:app --reload --port 8000

Get an Access Token

Fetch a token from Keycloak using the password grant (for testing only):

TOKEN=$(curl -s -X POST 
  http://localhost:8080/realms/your-realm/protocol/openid-connect/token 
  -H "Content-Type: application/x-www-form-urlencoded" 
  -d "client_id=fastapi-app" 
  -d "client_secret=your-client-secret" 
  -d "grant_type=password" 
  -d "username=testuser" 
  -d "password=testpassword" 
  | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")

Call Protected Endpoints

# Get current user info
curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/me

# List items (requires api-read role)
curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/items

# Create item (requires api-admin role)
curl -X POST -H "Authorization: Bearer $TOKEN" 
  "http://localhost:8000/items?item_name=NewItem"

# Admin endpoint
curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/admin/users

Interactive Documentation

Visit http://localhost:8000/docs to see FastAPI’s auto-generated Swagger UI. Click the Authorize button and paste your Bearer token to test endpoints interactively.

Step 9: Add Service Account Authentication

For service-to-service communication where no user is involved, use Keycloak’s client credentials grant:

# service_auth.py
import httpx
from config import get_settings

async def get_service_token() -> str:
    """Get an access token using client credentials (service account)."""
    settings = get_settings()

    async with httpx.AsyncClient() as client:
        response = await client.post(
            settings.token_url,
            data={
                "client_id": settings.keycloak_client_id,
                "client_secret": settings.keycloak_client_secret,
                "grant_type": "client_credentials",
            },
        )

    if response.status_code != 200:
        raise RuntimeError("Failed to obtain service token from Keycloak")

    return response.json()["access_token"]

Enable service accounts for your client in Keycloak: go to Clients > fastapi-app > Settings and enable Service accounts roles. Then assign the appropriate roles under the Service account roles tab.

Security Best Practices

Before going to production, review these recommendations:

  1. Use short-lived access tokens. Configure Keycloak to issue access tokens with a 5-15 minute lifetime. Clients should use refresh tokens to get new access tokens silently.

  2. Always validate the iss and aud claims. The code above does this. Without issuer validation, a token from a different Keycloak realm or a different OIDC provider could be accepted.

  3. Enable audit logging. Track who authenticated, when, and from where. This is essential for security investigations and compliance. Our guide on auditing in Keycloak covers this in detail.

  4. Enforce HTTPS in production. Never transmit tokens over unencrypted connections. If you’re running Keycloak behind a reverse proxy, see our guide on how to run Keycloak behind a reverse proxy.

  5. Consider MFA for users with elevated privileges. Keycloak supports conditional MFA policies based on roles or client access.

  6. Monitor session activity. Keycloak provides session management APIs that let you view and terminate active sessions.

What’s Next

With your FastAPI application secured, consider these next steps:


Don’t want to manage Keycloak infrastructure? Skycloak handles the deployment, scaling, backups, and upgrades so you can focus on building your API. Check out our pricing or explore our hosting options.

Guilliano Molaire
Written by Guilliano Molaire Founder

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

Ready to simplify your authentication?

Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.

© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman