Flask + Keycloak: Add Authentication to Your Python API
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
- Log into
http://localhost:8080/admin - 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):
- Go to Clients > Create client
- Set:
- Client type: OpenID Connect
- Client ID:
flask-backend
- Capability Config:
- Client authentication: On (confidential client)
- Service accounts roles: Enabled (if your API needs to call Keycloak’s Admin API)
- Standard flow: Enabled
- Login Settings:
- Valid redirect URIs:
http://localhost:5000/* - Web origins:
http://localhost:5000
- Valid redirect URIs:
- After saving, go to the Credentials tab and copy the Client secret
Frontend Client (for testing with a browser):
- Create another client with Client ID:
flask-frontend - Client authentication: Off (public client)
- Valid redirect URIs:
http://localhost:5000/*
Create Roles and Users
- Go to Realm roles > create
userandadminroles - Go to Users > create a test user with a password
- Assign the
userrole 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:

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:
- Fetches the JWKS: Downloads Keycloak’s public keys from the
.well-known/openid-configurationJWKS URI. These are cached byPyJWKClient. - Matches the signing key: Uses the
kid(Key ID) header from the JWT to find the matching public key. - Verifies the signature: Confirms the token was signed by Keycloak using RS256.
- Checks expiration: Rejects tokens where
expis in the past. - Validates the issuer: Ensures
issmatches 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:
-
Use HTTPS: Keycloak tokens contain sensitive information. Never transmit them over unencrypted connections. Skycloak handles TLS termination automatically.
-
Validate the audience: In production, enable audience verification by adding
"verify_aud": Trueand specifying your client ID as the expected audience. You may need to configure an audience mapper in your Keycloak client. -
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.
-
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.
-
Enable session management: Use Keycloak’s session management to control concurrent sessions and enable session revocation.
-
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:
- Adding identity provider federation for social and enterprise logins
- Implementing fine-grained authorization beyond role checks
- Setting up the Keycloak Config Generator to automate realm configuration
- Reading the Keycloak Securing Applications guide for advanced integration patterns
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.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.