Keycloak access-token validation with a framework-agnostic Principal and adapters for Django REST Framework, FastAPI, and Flask.
Project description
keycloak-guard
Validate Keycloak access tokens once, the same way, in every Python service.
One dependency-light core does the "validate-then-classify" work; thin adapters wrap it for Django REST Framework, FastAPI, and Flask, so every service in your fleet authenticates identically and returns the same machine-readable error body.
What it does
- Validate — resolves the signing key by
kidfrom the realm JWKS, verifies the signature with a pinned algorithm (RS256by default — never derived from the token, the defense against algorithm-confusion attacks, RFC 8725), and checksiss,aud,exp, and required claims. - Classify — decides whether the caller is a human
USER(interactive authorization-code/PKCE login) or aSERVICE(client-credentials grant), preferring an explicit role you assign and falling back to Keycloak'sservice-account-<clientId>username convention. - Authorize — exposes realm + client roles and scopes on a framework-agnostic
Principal.
The core depends only on pyjwt[crypto]. Framework adapters import their
framework lazily, so installing the core never drags in Django, FastAPI, or Flask.
Install
pip install keycloak-guard # core only
pip install "keycloak-guard[drf]" # + Django REST Framework adapter
pip install "keycloak-guard[fastapi]" # + FastAPI adapter
pip install "keycloak-guard[flask]" # + Flask adapter
pip install "keycloak-guard[all]" # everything
Requires Python 3.10+.
Quick start
from keycloak_guard import KeycloakAuthConfig, TokenValidator
config = KeycloakAuthConfig(
issuer="https://kc.example.com/realms/myrealm", # must equal the token's iss
audience="orders-api", # this service
# jwks_url defaults to {issuer}/protocol/openid-connect/certs
user_role="end-user", # optional, strengthens classification
service_role="api-client", # optional
)
validator = TokenValidator(config)
principal = validator.validate(bearer_token) # -> Principal, or raises AuthError
principal.subject # "sub" claim
principal.is_service # client-credentials caller?
principal.has_role("admin")
principal.has_scope("orders.read")
Audience gotcha: Keycloak's default access-token
audis often justaccount, not your API. Add an audience mapper / client scope in Keycloak so your service appears inaud, or validation will (correctly) reject the token. For a transitional period you can setverify_aud=False— this also stops requiring theaudclaim — but treat that as temporary.
Use per framework
Django REST Framework
# myapp/auth.py
from keycloak_guard import KeycloakAuthConfig, TokenValidator
from keycloak_guard.contrib.drf import KeycloakAuthentication
class MyKeycloakAuthentication(KeycloakAuthentication):
validator = TokenValidator(KeycloakAuthConfig(issuer=..., audience="orders-api"))
def get_user(self, principal):
# optional: JIT-provision a Django user keyed on principal.subject
return principal
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"myapp.auth.MyKeycloakAuthentication",
"rest_framework.authentication.TokenAuthentication", # legacy, dual-auth
]
}
Non-Bearer requests return None, so DRF falls through to the next
authenticator — that's the dual-auth mechanism for gradual migrations.
FastAPI
from fastapi import Depends, FastAPI
from keycloak_guard import KeycloakAuthConfig, TokenValidator
from keycloak_guard.contrib.fastapi import KeycloakAuth, require_roles
app = FastAPI()
auth = KeycloakAuth(TokenValidator(KeycloakAuthConfig(issuer=..., audience="orders-api")))
@app.get("/me")
async def me(principal=Depends(auth)):
return {"sub": principal.subject, "type": principal.type}
@app.get("/admin", dependencies=[Depends(require_roles(auth, "admin"))])
async def admin(): ...
Flask
from flask import Flask, g
from keycloak_guard import KeycloakAuthConfig, TokenValidator
from keycloak_guard.contrib.flask import keycloak_protected
app = Flask(__name__)
validator = TokenValidator(KeycloakAuthConfig(issuer=..., audience="billing-api"))
@app.get("/me")
@keycloak_protected(validator)
def me():
return {"sub": g.principal.subject}
@app.get("/admin")
@keycloak_protected(validator, roles=["admin"])
def admin(): ...
Error shape (consistent across frameworks)
On a token rejection every adapter returns the same canonical body, so any
client can react uniformly — key on error to tell "the backend refused this
token" apart from an unrelated 401 (a proxy, a sub-resource, a view's own error):
{ "error": "keycloak_auth_failed", "code": "invalid_audience", "detail": "Invalid audience" }
error— constant marker; the same for every auth failure.code— the specific reason:invalid_audience,token_expired,token_not_active,invalid_issuer,invalid_signature,missing_claim,invalid_algorithm,missing_token,invalid_header,key_error,invalid_token.detail— human-readable message.
Role failures return 403 with error: "keycloak_access_denied" and
code: "missing_role" — distinct from a rejected token, same shape.
All 401 responses also carry WWW-Authenticate: Bearer. DRF and Flask
return the dict at the top level; FastAPI nests it under its usual detail
key (body["detail"]["error"] == "keycloak_auth_failed"). The source of truth
is AuthError.to_dict(), so custom adapters stay consistent for free.
Configuration reference
| Field | Default | Purpose |
|---|---|---|
issuer |
— (required) | Realm issuer URL; must equal the token's iss. Trailing slash stripped. |
audience |
— (required) | Accepted audience(s); token passes if its aud contains any one. |
jwks_url |
{issuer}/protocol/openid-connect/certs |
Override the JWKS endpoint. |
algorithms |
("RS256",) |
Pinned signature algorithms. Never widened from the token. |
leeway_seconds |
30 |
Clock-skew tolerance for exp/iat. |
required_claims |
exp, iat, iss, aud, sub |
Claims that must be present. |
verify_aud |
True |
Disable to skip audience verification (and the aud requirement). Temporary use only. |
user_role / service_role |
None |
Explicit roles for USER/SERVICE classification (strongest signal). |
service_account_username_prefix |
"service-account-" |
Fallback classification for client-credentials callers. |
bare_client_roles |
True |
Also expose client roles by bare name. Bare names collide across clients — set False to require client:role. |
jwks_cache_lifespan_seconds |
300 |
PyJWT JWKS cache lifetime. |
jwks_timeout_seconds |
30 |
JWKS fetch timeout. |
verify_ssl |
True |
TLS verification for the JWKS fetch. Never False in production. |
Custom key resolution
TokenValidator(config, key_resolver=...) accepts any KeyResolver.
StaticKeyResolver(public_pem) pins a single key (great for tests);
JWKSKeyResolver is the production default.
Security notes
- Algorithms are pinned in config and never read from the token header —
alg=noneand HS256/RS256 confusion attacks are rejected at decode. - The
audcheck is on by default; a token minted for another service is rejected even if the signature is valid. - With
bare_client_roles=True(the default),has_role("admin")matches anadminrole from any client inresource_access. If different clients in your realm define same-named roles with different meanings, set it toFalseand check namespaced roles ("orders-api:admin").
Tests
pip install -e ".[dev]"
pytest
The suite mints tokens with a local RSA key and asserts the validator accepts a
good token and rejects expired, wrong-audience, wrong-issuer, tampered-signature,
alg=none, HS256/algorithm-confusion, and missing-claim tokens, plus both
classification paths — no network or live Keycloak needed.
License
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file keycloak_guard-0.1.0.tar.gz.
File metadata
- Download URL: keycloak_guard-0.1.0.tar.gz
- Upload date:
- Size: 19.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b9f37a7056d4a0a8fe1716687ae242ffae8cd44c3db9c6f36acea1fa56ee6e94
|
|
| MD5 |
2c948eda15dbefeecac3011bb06ab92d
|
|
| BLAKE2b-256 |
51124bcff872875cfd6912708f3ffb90d312df8ac04a8b46f94aa37bd5c725e8
|
Provenance
The following attestation bundles were made for keycloak_guard-0.1.0.tar.gz:
Publisher:
publish.yml on Dinakar2329/keycloak-guard
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
keycloak_guard-0.1.0.tar.gz -
Subject digest:
b9f37a7056d4a0a8fe1716687ae242ffae8cd44c3db9c6f36acea1fa56ee6e94 - Sigstore transparency entry: 2188368143
- Sigstore integration time:
-
Permalink:
Dinakar2329/keycloak-guard@059a10d81f91bd5d95068ab06b3cef450c54e6d2 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/Dinakar2329
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@059a10d81f91bd5d95068ab06b3cef450c54e6d2 -
Trigger Event:
release
-
Statement type:
File details
Details for the file keycloak_guard-0.1.0-py3-none-any.whl.
File metadata
- Download URL: keycloak_guard-0.1.0-py3-none-any.whl
- Upload date:
- Size: 17.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
07142f5479bd94584b924e3380cf635da34e6f3c4a0e64f0a88f8063437923db
|
|
| MD5 |
fd03f54a8e476fab22ea9451b91e9c17
|
|
| BLAKE2b-256 |
a185d29202d1b93c8d06ad5fe3aa4f35f93ecb9b201c2e709086d12fc67314a8
|
Provenance
The following attestation bundles were made for keycloak_guard-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on Dinakar2329/keycloak-guard
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
keycloak_guard-0.1.0-py3-none-any.whl -
Subject digest:
07142f5479bd94584b924e3380cf635da34e6f3c4a0e64f0a88f8063437923db - Sigstore transparency entry: 2188368154
- Sigstore integration time:
-
Permalink:
Dinakar2329/keycloak-guard@059a10d81f91bd5d95068ab06b3cef450c54e6d2 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/Dinakar2329
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@059a10d81f91bd5d95068ab06b3cef450c54e6d2 -
Trigger Event:
release
-
Statement type: