Flask + Keycloak: Add Authentication to Your Python API

Guilliano Molaire Guilliano Molaire Updated April 6, 2026 12 min read

Last updated: March 2026

Flask’s minimalism makes it a popular choice for building Python APIs, but it deliberately leaves authentication out of scope. Adding identity management to a Flask application typically means either writing custom token validation code (fragile and hard to maintain) or adopting an opinionated auth library that may not fit your needs.

Keycloak offers a standards-based alternative. It handles user management, single sign-on, multi-factor authentication, and role-based access control as a standalone service. Your Flask API validates the JWT tokens Keycloak issues and enforces permissions without storing any credentials.

This guide builds a Flask API that authenticates users through Keycloak using Authlib for OIDC integration and PyJWT for token validation. By the end, you will have working login/logout flows, protected endpoints, role-based decorators, and automatic token refresh.

Prerequisites

  • Python 3.10+ and pip
  • A running Keycloak instance (version 22+). Use the Skycloak Docker Compose Generator for a quick local setup, or a managed instance from Skycloak.
  • Basic familiarity with Flask and REST APIs

Start a local Keycloak for development:

docker run -p 8080:8080 
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin 
  -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin 
  quay.io/keycloak/keycloak:26.0 start-dev

Keycloak Configuration

Create a Realm

  1. Log into http://localhost:8080/admin
  2. Create a new realm called flask-api

Create a Client

You need two clients: one for the API (confidential) and one for the frontend (public).

API Client (backend):

  1. Go to Clients > Create client
  2. Set:
    • Client type: OpenID Connect
    • Client ID: flask-backend
  3. Capability Config:
    • Client authentication: On (confidential client)
    • Service accounts roles: Enabled (if your API needs to call Keycloak’s Admin API)
    • Standard flow: Enabled
  4. Login Settings:
    • Valid redirect URIs: http://localhost:5000/*
    • Web origins: http://localhost:5000
  5. After saving, go to the Credentials tab and copy the Client secret

Frontend Client (for testing with a browser):

  1. Create another client with Client ID: flask-frontend
  2. Client authentication: Off (public client)
  3. Valid redirect URIs: http://localhost:5000/*

Create Roles and Users

  1. Go to Realm roles > create user and admin roles
  2. Go to Users > create a test user with a password
  3. Assign the user role under Role mappings

Project Setup

Create a project directory and install dependencies:

mkdir flask-keycloak && cd flask-keycloak
python -m venv venv
source venv/bin/activate

Create requirements.txt:

flask==3.1.*
authlib==1.4.*
PyJWT[crypto]==2.10.*
requests==2.32.*
python-dotenv==1.0.*
flask-cors==5.0.*

Install:

pip install -r requirements.txt

Create a .env file:

KEYCLOAK_URL=http://localhost:8080
KEYCLOAK_REALM=flask-api
KEYCLOAK_CLIENT_ID=flask-backend
KEYCLOAK_CLIENT_SECRET=your-client-secret-here
FLASK_SECRET_KEY=change-this-to-a-random-string

Application Structure

Organize the project with blueprints:

Flask Keycloak project directory structure with app, auth, and api modules

Configuration

Create app/config.py:

import os
from dotenv import load_dotenv

load_dotenv()

class Config:
    SECRET_KEY = os.environ.get("FLASK_SECRET_KEY", "dev-secret-key")

    KEYCLOAK_URL = os.environ.get("KEYCLOAK_URL", "http://localhost:8080")
    KEYCLOAK_REALM = os.environ.get("KEYCLOAK_REALM", "flask-api")
    KEYCLOAK_CLIENT_ID = os.environ.get("KEYCLOAK_CLIENT_ID", "flask-backend")
    KEYCLOAK_CLIENT_SECRET = os.environ.get("KEYCLOAK_CLIENT_SECRET", "")

    @property
    def KEYCLOAK_ISSUER(self) -> str:
        return f"{self.KEYCLOAK_URL}/realms/{self.KEYCLOAK_REALM}"

    @property
    def KEYCLOAK_JWKS_URI(self) -> str:
        return (
            f"{self.KEYCLOAK_URL}/realms/{self.KEYCLOAK_REALM}"
            f"/protocol/openid-connect/certs"
        )

    @property
    def KEYCLOAK_TOKEN_URL(self) -> str:
        return (
            f"{self.KEYCLOAK_URL}/realms/{self.KEYCLOAK_REALM}"
            f"/protocol/openid-connect/token"
        )

    @property
    def KEYCLOAK_AUTH_URL(self) -> str:
        return (
            f"{self.KEYCLOAK_URL}/realms/{self.KEYCLOAK_REALM}"
            f"/protocol/openid-connect/auth"
        )

    @property
    def KEYCLOAK_USERINFO_URL(self) -> str:
        return (
            f"{self.KEYCLOAK_URL}/realms/{self.KEYCLOAK_REALM}"
            f"/protocol/openid-connect/userinfo"
        )

    @property
    def KEYCLOAK_LOGOUT_URL(self) -> str:
        return (
            f"{self.KEYCLOAK_URL}/realms/{self.KEYCLOAK_REALM}"
            f"/protocol/openid-connect/logout"
        )

Keycloak Integration Module

Create app/auth/keycloak.py:

import jwt
import requests
from functools import lru_cache
from jwt import PyJWKClient
from flask import current_app

@lru_cache(maxsize=1)
def get_jwk_client() -> PyJWKClient:
    """Create a cached JWK client for token verification."""
    config = current_app.config
    jwks_uri = (
        f"{config['KEYCLOAK_URL']}/realms/{config['KEYCLOAK_REALM']}"
        f"/protocol/openid-connect/certs"
    )
    return PyJWKClient(jwks_uri)

def validate_token(token: str) -> dict:
    """
    Validate a JWT access token from Keycloak.

    Returns the decoded token payload if valid.
    Raises jwt.InvalidTokenError or subclasses on failure.
    """
    config = current_app.config
    issuer = (
        f"{config['KEYCLOAK_URL']}/realms/{config['KEYCLOAK_REALM']}"
    )

    jwk_client = get_jwk_client()
    signing_key = jwk_client.get_signing_key_from_jwt(token)

    decoded = jwt.decode(
        token,
        signing_key.key,
        algorithms=["RS256"],
        issuer=issuer,
        options={
            "verify_aud": False,  # Keycloak access tokens may not have aud
            "verify_exp": True,
            "verify_iss": True,
        },
    )

    return decoded

def get_user_roles(token_payload: dict) -> list[str]:
    """Extract realm roles from a decoded Keycloak token."""
    realm_access = token_payload.get("realm_access", {})
    return realm_access.get("roles", [])

def get_client_roles(token_payload: dict, client_id: str) -> list[str]:
    """Extract client-specific roles from a decoded Keycloak token."""
    resource_access = token_payload.get("resource_access", {})
    client_access = resource_access.get(client_id, {})
    return client_access.get("roles", [])

def exchange_code_for_tokens(code: str, redirect_uri: str) -> dict:
    """Exchange an authorization code for tokens."""
    config = current_app.config
    token_url = (
        f"{config['KEYCLOAK_URL']}/realms/{config['KEYCLOAK_REALM']}"
        f"/protocol/openid-connect/token"
    )

    response = requests.post(
        token_url,
        data={
            "grant_type": "authorization_code",
            "code": code,
            "redirect_uri": redirect_uri,
            "client_id": config["KEYCLOAK_CLIENT_ID"],
            "client_secret": config["KEYCLOAK_CLIENT_SECRET"],
        },
        timeout=10,
    )

    response.raise_for_status()
    return response.json()

def refresh_access_token(refresh_token: str) -> dict:
    """Use a refresh token to get new access and refresh tokens."""
    config = current_app.config
    token_url = (
        f"{config['KEYCLOAK_URL']}/realms/{config['KEYCLOAK_REALM']}"
        f"/protocol/openid-connect/token"
    )

    response = requests.post(
        token_url,
        data={
            "grant_type": "refresh_token",
            "refresh_token": refresh_token,
            "client_id": config["KEYCLOAK_CLIENT_ID"],
            "client_secret": config["KEYCLOAK_CLIENT_SECRET"],
        },
        timeout=10,
    )

    response.raise_for_status()
    return response.json()

Authentication Decorators

Create app/auth/decorators.py:

from functools import wraps
from flask import request, jsonify, g
from app.auth.keycloak import validate_token, get_user_roles

def require_auth(f):
    """Decorator that requires a valid Keycloak access token."""

    @wraps(f)
    def decorated(*args, **kwargs):
        auth_header = request.headers.get("Authorization")

        if not auth_header:
            return jsonify({"error": "Missing Authorization header"}), 401

        parts = auth_header.split()
        if len(parts) != 2 or parts[0].lower() != "bearer":
            return jsonify({"error": "Invalid Authorization header format"}), 401

        token = parts[1]

        try:
            payload = validate_token(token)
        except Exception as e:
            return jsonify({"error": f"Invalid token: {str(e)}"}), 401

        # Store decoded token and roles in Flask's g object
        g.token_payload = payload
        g.user_id = payload.get("sub")
        g.username = payload.get("preferred_username")
        g.email = payload.get("email")
        g.roles = get_user_roles(payload)

        return f(*args, **kwargs)

    return decorated

def require_role(*required_roles):
    """
    Decorator that requires the user to have at least one
    of the specified roles.

    Usage:
        @require_role("admin")
        @require_role("admin", "manager")
    """

    def decorator(f):
        @wraps(f)
        @require_auth
        def decorated(*args, **kwargs):
            user_roles = set(g.roles)
            if not user_roles.intersection(required_roles):
                return (
                    jsonify(
                        {
                            "error": "Insufficient permissions",
                            "required_roles": list(required_roles),
                            "your_roles": list(user_roles),
                        }
                    ),
                    403,
                )
            return f(*args, **kwargs)

        return decorated

    return decorator

The require_auth decorator validates the JWT from the Authorization header and stores the decoded payload in Flask’s g object, making user information available throughout the request. The require_role decorator builds on top of it, adding role checking. You can use the JWT Token Analyzer to inspect the tokens and verify that your roles and claims are structured correctly.

Auth Routes

Create app/auth/routes.py:

from urllib.parse import urlencode
from flask import Blueprint, redirect, request, jsonify, session, current_app
from app.auth.keycloak import exchange_code_for_tokens, refresh_access_token

auth_bp = Blueprint("auth", __name__, url_prefix="/auth")

@auth_bp.route("/login")
def login():
    """Redirect the user to Keycloak's login page."""
    config = current_app.config
    redirect_uri = request.url_root.rstrip("/") + "/auth/callback"

    auth_url = (
        f"{config['KEYCLOAK_URL']}/realms/{config['KEYCLOAK_REALM']}"
        f"/protocol/openid-connect/auth"
    )

    params = urlencode(
        {
            "client_id": config["KEYCLOAK_CLIENT_ID"],
            "response_type": "code",
            "scope": "openid email profile",
            "redirect_uri": redirect_uri,
        }
    )

    return redirect(f"{auth_url}?{params}")

@auth_bp.route("/callback")
def callback():
    """Handle the OAuth2 callback from Keycloak."""
    code = request.args.get("code")
    error = request.args.get("error")

    if error:
        return jsonify({"error": error}), 400

    if not code:
        return jsonify({"error": "Missing authorization code"}), 400

    redirect_uri = request.url_root.rstrip("/") + "/auth/callback"

    try:
        tokens = exchange_code_for_tokens(code, redirect_uri)
    except Exception as e:
        return jsonify({"error": f"Token exchange failed: {str(e)}"}), 500

    # In a real app, you might set these in an HTTP-only cookie
    # or return them for the frontend to store
    session["access_token"] = tokens["access_token"]
    session["refresh_token"] = tokens["refresh_token"]
    session["id_token"] = tokens.get("id_token")

    return jsonify(
        {
            "message": "Login successful",
            "access_token": tokens["access_token"],
            "refresh_token": tokens["refresh_token"],
            "expires_in": tokens["expires_in"],
        }
    )

@auth_bp.route("/refresh", methods=["POST"])
def refresh():
    """Refresh the access token using the refresh token."""
    refresh_token = request.json.get("refresh_token") if request.json else None

    if not refresh_token:
        refresh_token = session.get("refresh_token")

    if not refresh_token:
        return jsonify({"error": "No refresh token provided"}), 400

    try:
        tokens = refresh_access_token(refresh_token)
    except Exception as e:
        return jsonify({"error": f"Token refresh failed: {str(e)}"}), 401

    session["access_token"] = tokens["access_token"]
    session["refresh_token"] = tokens["refresh_token"]

    return jsonify(
        {
            "access_token": tokens["access_token"],
            "refresh_token": tokens["refresh_token"],
            "expires_in": tokens["expires_in"],
        }
    )

@auth_bp.route("/logout", methods=["POST"])
def logout():
    """Log the user out of Keycloak and clear the session."""
    config = current_app.config
    refresh_token = session.get("refresh_token")

    logout_url = (
        f"{config['KEYCLOAK_URL']}/realms/{config['KEYCLOAK_REALM']}"
        f"/protocol/openid-connect/logout"
    )

    if refresh_token:
        import requests as http_requests

        try:
            http_requests.post(
                logout_url,
                data={
                    "client_id": config["KEYCLOAK_CLIENT_ID"],
                    "client_secret": config["KEYCLOAK_CLIENT_SECRET"],
                    "refresh_token": refresh_token,
                },
                timeout=10,
            )
        except Exception:
            pass  # Best effort logout

    session.clear()
    return jsonify({"message": "Logged out successfully"})

Protected API Routes

Create app/api/routes.py:

from flask import Blueprint, jsonify, g
from app.auth.decorators import require_auth, require_role

api_bp = Blueprint("api", __name__, url_prefix="/api")

@api_bp.route("/public")
def public_endpoint():
    """Accessible without authentication."""
    return jsonify({"message": "This is a public endpoint"})

@api_bp.route("/protected")
@require_auth
def protected_endpoint():
    """Requires a valid access token."""
    return jsonify(
        {
            "message": "You are authenticated",
            "user_id": g.user_id,
            "username": g.username,
            "email": g.email,
            "roles": g.roles,
        }
    )

@api_bp.route("/admin")
@require_role("admin")
def admin_endpoint():
    """Requires the admin role."""
    return jsonify(
        {
            "message": "Welcome, admin",
            "username": g.username,
        }
    )

@api_bp.route("/manager")
@require_role("admin", "manager")
def manager_endpoint():
    """Requires admin or manager role."""
    return jsonify(
        {
            "message": "Welcome to the management area",
            "username": g.username,
            "roles": g.roles,
        }
    )

@api_bp.route("/profile")
@require_auth
def user_profile():
    """Return the full decoded token payload."""
    return jsonify(
        {
            "sub": g.user_id,
            "preferred_username": g.username,
            "email": g.email,
            "roles": g.roles,
            "token_claims": {
                k: v
                for k, v in g.token_payload.items()
                if k
                not in ("exp", "iat", "auth_time", "jti", "typ", "azp", "sid")
            },
        }
    )

Application Factory

Create app/__init__.py:

from flask import Flask
from flask_cors import CORS
from app.config import Config

def create_app(config_class=Config):
    app = Flask(__name__)
    app.config.from_object(config_class)

    # Enable CORS for frontend integration
    CORS(
        app,
        origins=["http://localhost:5173", "http://localhost:3000"],
        supports_credentials=True,
    )

    # Register blueprints
    from app.auth.routes import auth_bp
    from app.api.routes import api_bp

    app.register_blueprint(auth_bp)
    app.register_blueprint(api_bp)

    # Health check
    @app.route("/health")
    def health():
        return {"status": "healthy"}

    return app

Create run.py:

from app import create_app

app = create_app()

if __name__ == "__main__":
    app.run(debug=True, port=5000)

Create app/auth/__init__.py and app/api/__init__.py as empty files.

Running and Testing

Start the Flask application:

python run.py

Test the Public Endpoint

curl http://localhost:5000/api/public

Get an Access Token

You can get a token through the browser-based login flow (/auth/login) or directly via the token endpoint for testing:

curl -X POST http://localhost:8080/realms/flask-api/protocol/openid-connect/token 
  -d "grant_type=password" 
  -d "client_id=flask-backend" 
  -d "client_secret=your-client-secret" 
  -d "username=testuser" 
  -d "password=testpassword" 
  -d "scope=openid"

Test Protected Endpoints

# Replace $TOKEN with the access_token from the previous response
curl -H "Authorization: Bearer $TOKEN" 
  http://localhost:5000/api/protected

curl -H "Authorization: Bearer $TOKEN" 
  http://localhost:5000/api/profile

Test Role-Based Endpoints

# This will return 403 unless the user has the admin role
curl -H "Authorization: Bearer $TOKEN" 
  http://localhost:5000/api/admin

If you get a 403 response, check our 403 Forbidden troubleshooting guide for common causes and fixes.

Refresh a Token

curl -X POST http://localhost:5000/auth/refresh 
  -H "Content-Type: application/json" 
  -d '{"refresh_token": "your-refresh-token"}'

Error Handling

Add centralized error handling for common JWT errors. Update app/__init__.py:

from flask import Flask, jsonify
from flask_cors import CORS
from app.config import Config
import jwt

def create_app(config_class=Config):
    app = Flask(__name__)
    app.config.from_object(config_class)

    CORS(
        app,
        origins=["http://localhost:5173", "http://localhost:3000"],
        supports_credentials=True,
    )

    # Error handlers
    @app.errorhandler(jwt.ExpiredSignatureError)
    def handle_expired_token(e):
        return jsonify({"error": "Token has expired"}), 401

    @app.errorhandler(jwt.InvalidTokenError)
    def handle_invalid_token(e):
        return jsonify({"error": f"Invalid token: {str(e)}"}), 401

    @app.errorhandler(404)
    def handle_not_found(e):
        return jsonify({"error": "Not found"}), 404

    @app.errorhandler(500)
    def handle_server_error(e):
        return jsonify({"error": "Internal server error"}), 500

    from app.auth.routes import auth_bp
    from app.api.routes import api_bp

    app.register_blueprint(auth_bp)
    app.register_blueprint(api_bp)

    @app.route("/health")
    def health():
        return {"status": "healthy"}

    return app

Token Validation Details

Understanding what the token validation does is important for debugging issues. When validate_token runs, it:

  1. Fetches the JWKS: Downloads Keycloak’s public keys from the .well-known/openid-configuration JWKS URI. These are cached by PyJWKClient.
  2. Matches the signing key: Uses the kid (Key ID) header from the JWT to find the matching public key.
  3. Verifies the signature: Confirms the token was signed by Keycloak using RS256.
  4. Checks expiration: Rejects tokens where exp is in the past.
  5. Validates the issuer: Ensures iss matches your Keycloak realm URL.

If you need to inspect tokens during development, the JWT Token Analyzer decodes and displays all claims without requiring code changes.

For SAML-based integrations with enterprise identity providers, you can use the SAML Decoder to inspect SAML assertions. See our guide on configuring Keycloak as a SAML Service Provider for that setup.

Monitoring and Logging

For production deployments, add structured logging for authentication events:

import logging

logger = logging.getLogger("auth")

def require_auth(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        auth_header = request.headers.get("Authorization")

        if not auth_header:
            logger.warning(
                "Missing auth header",
                extra={"path": request.path, "ip": request.remote_addr},
            )
            return jsonify({"error": "Missing Authorization header"}), 401

        # ... validation logic ...

        logger.info(
            "Authenticated request",
            extra={
                "user_id": g.user_id,
                "username": g.username,
                "path": request.path,
            },
        )

        return f(*args, **kwargs)

    return decorated

Keycloak itself provides audit logging for all authentication events. Combined with Flask’s request logging, you get a complete picture of who is accessing what and when. For centralized monitoring, Skycloak’s insights dashboard provides real-time visibility into your authentication patterns.

Production Considerations

Before deploying to production:

  1. Use HTTPS: Keycloak tokens contain sensitive information. Never transmit them over unencrypted connections. Skycloak handles TLS termination automatically.

  2. Validate the audience: In production, enable audience verification by adding "verify_aud": True and specifying your client ID as the expected audience. You may need to configure an audience mapper in your Keycloak client.

  3. Use HTTP-only cookies: Instead of returning tokens in JSON responses, store them in HTTP-only, Secure, SameSite cookies to prevent XSS attacks from stealing tokens.

  4. Set appropriate token lifespans: Access tokens should be short-lived (5-15 minutes) with refresh tokens lasting longer. Configure these in Keycloak’s realm settings under Tokens.

  5. Enable session management: Use Keycloak’s session management to control concurrent sessions and enable session revocation.

  6. Monitor with SCIM: If you need automated user provisioning from external systems, Keycloak supports SCIM. Test your SCIM endpoints with our SCIM Endpoint Tester.

Next Steps

With authentication working, consider:

Try Skycloak

Skip the infrastructure management and focus on your Flask API. Skycloak provides fully managed Keycloak with automatic updates, backups, and high availability. See our pricing or check the documentation to get started.

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