Authentication (OIDC / OAuth2) and the Admin REST API for Keycloak behind one consistent facade, with hardened JWT validation and a full async mirror.
Part of a nine-language polyglot SDK (Java · Python · Node · Go · C# · PHP · Rust · Ruby · Kotlin) — one API surface, isomorphic across all of them: github.com/xzawed/KeyCloakSDK.
1.0.7is on PyPI — a barepip install keycloak-sdkresolves it. A patch release on top of1.0.6; no public API changed. Security: the admin client now holds the access token it sends as a Bearer to one rule shared by all nine SDKs: leading and trailing SP, HTAB, CR and LF are stripped, and unless what remains is a non-empty RFC 6750b64token(ASCII letters and digits and-._~+/, with=only at the end) the request is not sent and the call fails withKeycloakAuthError, which does not quote the token; the SDK's token providers do not cache such a token. Sync andaionow judge alike: sync used to send a leading tab, an inner space, Latin-1, C1 or NBSP as is,aiosent an inner space, and both rejected a trailing newline instead of stripping it.⚠️ A token that ends in a newline used to be rejected and is now stripped and sent. The responses the SDK reads itself (token, introspection, JWKS and admin) are now read in full within the configured timeout, counted from when the request is sent. The timeout used to apply to each read only, so a server that trickled bytes, each just inside the read timeout, held one call for about the size cap times the read timeout (7 seconds with a 1-second setting); such a call now ends with the existing timeout error, within the deadline plus one read (exactly at the deadline onaio). The sync lane also checks that deadline on every raw read from the socket — the status line, headers, chunk-size lines, trailers and TLS records included — because with the body deadline alone, a server that trickled those held one http.client call for 7 seconds (1-second setting); it now ends at about 1.0 second with the sameKeycloakTransportError. Still open: an HTTPS proxy (TLS inside TLS). A JWKS key set now expires 300 seconds after the load that fetched it finished, and an expired set is treated as a cold cache: the next validation fetches it again through the cold-load path (the same single flight and the same failure backoff) and rejects the token if that fetch fails, instead of validating with the stale keys. Until now the Python SDK gave a key set no lifetime, so it kept accepting a key the IdP had removed until a token with a newkidarrived.⚠️ If the JWKS cannot be reached for more than 300 seconds, validation fails — by design, because the SDK cannot tell an unreachable JWKS from a key the IdP has removed. The failure backoff's jitter (×[0.5, 1.0)) was drawn again at every check, so the same failure could refuse a validation at 190 ms and send the next one to the IdP at 191 ms; it is now drawn once, when the failure happens. The backoff's exponent had no cap, so after about 1,025 consecutive failures anOverflowErrorkept the JWKS from ever being fetched again, even once the IdP was back (reproduced at 1,100); the exponent is now capped at 16. Fixed: this README saidkeycloak_sdk.aiodoes not block the event loop — every request is awaited, butAsyncKeycloakClient.create(config)and the firstkc.adminaccess build the HTTP client and read the TLS trust store on the calling thread, which can take close to 1 second on Windows. The README now says so: create the client once when the application starts and reuse it. The behaviour is unchanged. Changed:⚠️ a sync response now fails withKeycloakTransportErrorwhen it exceeds its byte budget, the body cap plus 65,536 bytes — 1,114,112 for token, introspection, logout and admin error responses, 116,736 for the JWKS, and none for an admin success body. Sync used to receive 16 MiB of trailers (on CPython versions that do not bound the number of trailers) and the tail after a gzip token body, and an admin error response with large framing came out asKeycloakNotFoundErrorand the like. A JWKS error message may now carryHTTP response exceeds 116736 bytes on the wire.⚠️ An admin response — lists andrawsuccess bodies included — must now arrive in full within the configured timeout (the response deadline above); a large list over a slow link used to be received to its end and is now a timeout error, so raise the timeout if you fetch large lists over a slow link. The Go, Rust, Node, PHP and .NET admin clients already behaved this way.⚠️ The fourKeycloakConfigErrormessages that were in Korean are now in English —admin API access requires client_secret (client-credentials grant)andadmin access requires config (use KeycloakClient.create(config)), in the same form onaio. The exception types and cause chains are unchanged; only code that compares message strings is affected. The docstringshelp()and IDEs show for the public API (what__all__ofkeycloak_sdkandkeycloak_sdk.aioreaches) and the comments of the two examples this README links are now in English; the code is unchanged.1.0.0was the first release carrying the stability guarantee: a breaking change to the public API requires a major bump.
1.0.6was a patch release on top of1.0.5; no public API changed. Security: the admin client read a response body to its end even after it had the result. python-keycloak sends its requests withoutstream, so requests (sync) and httpx (aio) read the whole body before the result is looked at: a server wrote all of the 64 MiB it attached to the 201 body of acreateor the 2xx body of anupdateordelete, and a server that stalled after a 201's head turned acreatethat already had itsLocationinto aKeycloakTransportErrorat the end of the read timeout (4,008–4,019 ms with a 4-second timeout). Error bodies were read whole too — a 404 with 64 MiB put 67,108,864 characters inkeycloak_error(a peak of 201 MB for one call), a 65,278-byte gzip error body inflated to 64 MiB, and the tail after a gzip member was read to its end as well — sync andaioalike. The facade's write calls (create,updateanddeleteon the five resources, sync andaio) now leave a 2xx body unread (the server wrote at most 458,754 bytes; 2–10 ms in front of a stalled server, plaintext and TLS 1.3), and an error body (3xx and above) is held only up to 1,048,576 bytes, the token-response cap: one byte more is read to see whether it goes over, the SDK itself decompresses within that cap, and nothing after the end of a compressed stream is read (a peak of 3.5 MB for one call with a 64 MiB error body). The exception class (KeycloakNotFoundErrorand the like) is unchanged andkeycloak_erroris cut at that length. A python-keycloak method called throughrawstill receives a 2xx body (its error body has the same cap), success bodies (get,search, lists) are not capped, the connection of a response not read to its end is discarded, and the connection of a 2xx with no body (an empty 201, a 204) is reused as before. Admin requests now ask forAccept-Encoding: gzip, deflateonly — what the SDK can decompress within the cap.⚠️ Whenupdateordeletegets a 2xx with a body instead of a 204 (python-keycloak expects only 204), the error is unchanged but itskeycloak_erroris empty, and against a server that sends a JSON body with a 201,realms.createno longer raises theKeycloakTransportError(python-keycloak'sTypeError) it used to.validate()(sync andaio) also loaded the JWKS before it looked at the token, so on a cold cache one garbage token cost one IdP request. A token whoselen()exceeds 256,000 is now rejected withTokenValidationError(token exceeds 256000 bytes— not a signature-failure subtype, so it triggers no refetch) before the JWKS is loaded or the token decoded, and the id_token check of a code exchange does the same. joserfc's limit on the payload segment (128,000 bytes by default) is raised to this cap — left at its default, it would reject a genuinely signed token within the cap (255,993 characters) asTokenSignatureError, and Python would accept different tokens from the other eight languages. The 512-byte limit on the header segment, which is decoded as JSON before the signature, is unchanged.
1.0.5was a patch release on top of1.0.4; no public API changed. Security: when the admin client's own token grant fails,KeycloakAdminError.keycloak_erroris nowNone. It used to carry the token endpoint's error body, so an IdP that echoed what it received (Bad credentials: <secret>inerror_description) left the admin client secret — raw, form- or percent-encoded, or base64 asclient_id:secret— or the admin refresh token in that attribute (vars(e)), sync andaioalike. The type, the message (Keycloak admin error (HTTP <status>)) andstatus_codeare unchanged, and an error from an admin REST call itself still carries its body. An access token an HTTP header cannot carry — a C0 control character other than HTAB (CR, LF and NUL included), DEL, or a character the lane's transport cannot put in a header — is now refused withKeycloakAuthErrorbefore any admin request; the facade used to report some of these as a connection failure (KeycloakTransportError) and send others to the server inAuthorization. A token the SDK's admin grant receives is not cached (admin token grant returned an access token an HTTP header cannot carry (…); the next call requests a new one), and one that arrived another way — an injectedKeycloakAdmin(token=…)or theraw.connection.tokensetter — fails asadmin request not sent: …; neither message quotes the token.⚠️ A python-keycloak method called throughrawnow stops on such a token withKeycloakAuthErrorinstead ofkeycloak.exceptions.KeycloakConnectionError, and that error's chain no longer quotes the bearer as the one from requests or h11 did. Fixed: the sync client (KeycloakClient) no longer sends a request a second time on its own after the connection breaks. python-keycloak's session retry (max_retries=1, POST included — after a503withRetry-Afterit first slept that long) made the IdP receive token grants, introspection, logout, JWKS fetches, the admin client's creates, updates, deletes and reads, andrawactions twice, so a create could run twice and an authorization code or refresh token could be presented twice. The failure now raises the same SDK error at once (KeycloakTransportErrorfor a transport failure).⚠️ A call that a retry used to rescue on a connection that breaks once now fails, connection establishment included, and an adapter mounted throughrawhas its retry setting overwritten — retry the calls that are safe to repeat yourself.aionever sent a request twice and is unchanged.
⚠️ One breaking change since0.1.0, and it is narrow (it landed in0.2.0;1.0.0adds none): thekeycloak_sdk.jwtmodule moved tokeycloak_sdk._internal.jwt. Only code that importedJwtValidatorfrom that path directly is affected — it was never in__all__and appears in no quickstart. The normal validation path,kc.auth.validate(token), is unchanged. The move was structural:py.typedmakes every module's signatures part of the public type surface, so a module under the top level was publishing joserfc'sKeySetas part of this SDK's API.
- Python 3.10+
- Ships the PEP 561
py.typedmarker, so consumers can type-check withmypytoo
pip install keycloak-sdkThe distribution name is keycloak-sdk; the import package is keycloak_sdk.
from keycloak_sdk import KeycloakClient, KeycloakConfig
config = KeycloakConfig(
server_url="https://kc.example.com",
realm="myrealm",
client_id="admin-cli",
client_secret="changeme", # load the real value from an env var / secrets manager
)
# The `with` block closes both sessions on exit — the auth session and, since the admin facade owns its own `requests.Session` pair, those too (`AdminClient.close()` is not a no-op).
with KeycloakClient.create(config) as kc:
# 1) Issue a client-credentials token. repr(TokenSet) masks every token value.
token = kc.auth.client_credentials_token()
# 2) Validate it — algorithm pinning, exact iss, aud containment, mandatory exp, clock skew.
validated = kc.auth.validate(token.access_token)
print(f"subject={validated.subject} aud={validated.audience}")
# 3) Admin API — admin is created lazily on first access. create() returns the new user id.
user_id = kc.admin.users.create({"username": "alice", "enabled": True})
users = kc.admin.users.search(first=0, max=20)validate() expects the token's aud to contain client_id by default, but a stock realm does not put the client id into a client-credentials token. Either set expected_audience="my-api" on the config to check the audience your tokens actually carry, or add an audience mapper to the client in Keycloak (Client scopes → dedicated scope → Add mapper → Audience). expected_audience applies to access tokens only: the id_token that exchange_code(..., nonce=...) verifies must always carry client_id in aud (OIDC Core §3.1.3.7).
keycloak_sdk.aio is a complete async mirror — same method names (the sync close() is aclose() here), value types, and exceptions — and every network call is awaited, so requests do not block the event loop (FastAPI and friends). Construction does block it: AsyncKeycloakClient.create(config) and the first access to kc.admin build their HTTP clients and load the TLS trust store on the calling thread, which takes a noticeable fraction of a second on Windows. Create one client when the application starts and reuse it:
from keycloak_sdk import KeycloakConfig
from keycloak_sdk.aio import AsyncKeycloakClient
async def run(config: KeycloakConfig) -> None:
async with AsyncKeycloakClient.create(config) as kc:
token = await kc.auth.client_credentials_token()
validated = await kc.auth.validate(token.access_token)
users = await kc.admin.users.search(first=0, max=20)Among the client's methods, only authorization_url stays synchronous — it assembles a URL and needs no network.
The SDK replaces the unsafe library defaults rather than inheriting them:
- Algorithm pinning — the header-supplied
algis never trusted, soalg: noneand HS/RS confusion are rejected structurally: joserfc decodes against the configured allowlist, and an empty allowlist is refused at construction rather than falling back to joserfc's permissive default set. - Strict claim checks — exact
issmatch,audcontainment, mandatoryexp,nbfenforced when present, and a bounded clock skew. - DoS-safe JWKS — a key set is used for at most 300 s after its fetch completed; the next validation then fetches it again like a cold cache, and if that fetch fails the validation fails (no fallback to the old keys, even during an IdP outage). Within that age, a refetch is triggered only by an unresolved key ID and never by a bad signature, and is rate-limited to a minimum interval (
jwks_min_refetch_seconds, 30s by default) — so once the key set has been fetched, no volume of forged tokens makes the SDK issue more than one forced JWKS request per interval. A failed fetch is bounded separately: consecutive failures back off exponentially (0.2s, doubling, capped at 5s, with jitter), and inside that window the SDK fails fast without contacting the IdP. So a cold cache during an IdP outage no longer turns every validation into a request (measured: 20 attempts → 1 request, down from 20; the sync and async clients share the same state machine).⚠️ The SDK never sleeps — it returns the error immediately, so retry pacing stays the caller's decision. - Bounded token input — a token longer than 256,000 bytes is rejected with
TokenValidationErrorbefore it is decoded and before any JWKS request. - Secret handling —
repr()of the config and token types masks secrets and tokens as***(no prefix leak), and TLS verification is on by default.
Masking covers this SDK's own repr(); it cannot cover what your logging framework or a traceback does with a value you hand it. Python has no erasable string type, so the client secret lives in an ordinary str for its lifetime — masking is defence in depth, not an erasure guarantee.
This SDK is 1.0 and follows SemVer: a breaking change to the public API requires a major bump. That promise is machine-backed — CI diffs this lane's public API against the previously published artifact on every build (griffe check), and a removal or an incompatible change fails the build.
Only the newest released version of each language SDK receives security fixes; there are no long-term-support lines and older releases are not backported to.
Each of the nine languages versions independently. All nine reached 1.0.0 in the same release wave because they earned the same guarantee at the same time — they do not move in lockstep afterwards.
- Project overview — all nine languages, what is identical and what is not
- Changelog — read this before upgrading; breaking changes are listed per language
- Getting started — install and quickstart for this language
- Compatibility — which Keycloak server range and base libraries each published version shipped against
- Deploying a Keycloak server — the server this SDK talks to
- Security policy
- Full examples:
quickstart.py·async_quickstart.py