xzawed / keycloak-sdk
Keycloak SDK for PHP — OIDC/OAuth2 authentication + Admin REST API, part of a nine-language polyglot SDK
Requires
- php: ^8.3
- ext-curl: *
- firebase/php-jwt: ^7.1
- fschmtt/keycloak-rest-api-client-php: 0.44.0
- guzzlehttp/guzzle: ^7.15.2
- guzzlehttp/psr7: ^2.12.3
- league/oauth2-client: ^2.8
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- stevenmaguire/oauth2-keycloak: ^6.1
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- phpstan/phpstan: ^2.2
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^12
- roave/security-advisories: dev-latest
- testcontainers/testcontainers: ^1.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
An idiomatic PHP SDK for Keycloak covering both OIDC/OAuth2 authentication and the Admin REST API behind one consistent facade.
Part of a nine-language polyglot SDK (Java · Python · Node · Go · C# · PHP · Rust · Ruby · Kotlin) — one API shape, nine idioms: github.com/xzawed/KeyCloakSDK.
1.5.2is on Packagist — whereext-curlis enabled,composer require xzawed/keycloak-sdkresolves1.5.2under Composer's defaultminimum-stability: stable. A patch release on top of1.5.1. Changed: ⚠️ the token grants ofAuthClient(clientCredentialsToken,refreshandexchangeCode) now throwKeycloakAuthErrorinstead ofKeycloakTransportErrorwhen the token endpoint answers with a response that is not JSON (a 503 HTML page, a 200 with a non-JSON body and the like) or with JSON that has noaccess_token.ClientCredentialsTokenProvideralready threwKeycloakAuthErrorfor the same responses, andKeycloakTransportErrormeans a network failure only — code that caught those responses as a transport error to retry them should look at the auth error instead. The messages are unchanged, and so are a response over the cap (KeycloakTransportError) and an OAuth error response (KeycloakAuthError). No public API changed.
1.5.1was a patch release on top of1.5.0. Security: the admin lane now holds the access token it sends as a Bearer to one rule shared by all nine SDKs — after leading and trailing SP, HTAB, CR and LF are stripped, it must be a non-empty RFC 6750b64token(ASCII letters and digits and-._~+/, with=only at the end). Because the underlying admin library builds the Bearer itself, the admin lane accepts the access token of a grant response only when it already is such ab64token, with nothing to strip; anything else fails withKeycloakAdminError(no status), is not cached, and is not quoted. On PHP without sodium, a JWT's inner spaces and tabs used to be sent as is, and the psr7 exception fromraw()quoted the whole Bearer. The standaloneClientCredentialsTokenProviderstrips the leading and trailing characters and accepts. 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 PHP 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. Changed:composer.jsonnow requiresext-curland raises its floors toguzzlehttp/guzzle^7.15.2andguzzlehttp/psr7^2.12.3. Without curl, Guzzle fell back to PHP's stream handler, which read a response header line with no length limit (+57 MB for one 16 MiB line, with nothing to configure), and psr7 below 2.11 could not stop a transfer at the cap, so a large error body was received to its end. The two floors also close GHSA-v5mv-p594-2x33 and GHSA-f7vp-7xgx-4w4r (Guzzle) and GHSA-c2w2-prh8-qm98 (psr7). ⚠️ Withoutext-curl, Composer does not pick this release: a barecomposer require xzawed/keycloak-sdkwarns that it cannot use the latest version because it requiresext-curl, and installs1.5.0with exit code 0; a constraint naming1.5.1is refused (Your requirements could not be resolved …), and so iscomposer installfrom a lock that holds1.5.1(Your lock file does not contain a compatible set of packages …). The docblocks your IDE shows (classes and their public and protected members) and the comments ofexamples/quickstart.phpare now in English; the code and the PHPDoc tags and types are unchanged. No public API changed.
1.5.0was a minor release on top of1.4.1, and a security release. Security:validate()andvalidateIdToken()(and soexchangeCode()) split and JSON-decoded a token before checking its signature, with no length limit — an attacker-chosen 768 KiB token ended the request with a fatal out-of-memory error under PHP's defaultmemory_limit, the kind of attacker-reachable fatal error that PHP2Shell (CVE-2026-109618 / CVE-2026-109619) can turn into code execution on an unpatched runtime. A token longer than the new public constantJwtValidator::JWT_MAX_BYTES(256,000 bytes) is now rejected withTokenValidationErrorbefore it is split or decoded and without a JWKS request, and a token without exactly two dots is rejected before it is split. ⚠️ This does not patch your interpreter — see Requirements. The admin facade also stops receiving bodies it does not use: the 2xx body of a write (create,update,delete, andimportfor clients and realms) is no longer received, and an error body (3xx or above) is received only up to 1,048,576 bytes, so a server that sends 64 MiB no longer makes the call receive all of it or wait out the read timeout; status classes are unchanged and araw()error response is cut at the same length. ⚠️ Withoutext-curl, the stream handler stops the transfer at that cap only withguzzlehttp/psr72.11.0 or later. One public API addition (JwtValidator::JWT_MAX_BYTES); the measurements and caveats are in the changelog.⚠️ Coming from
0.1.0?roles()->update()changed signature. It takes the current name as its first argument —update(string $name, Role $role)— because the old one-argument form could not express a rename at all. See Upgrading from0.1.0.
Requirements
- PHP 8.3+ (
composer.jsonrequires^8.3)- ⚠️ Run a PHP build that carries the PHP2Shell fixes (CVE-2026-109618 / CVE-2026-109619) — 8.3.36, 8.4.27, 8.5.12 or later, or your distribution's patched package. The SDK cannot protect an unpatched interpreter, and Composer cannot enforce this for you: distributions backport the fix without changing the version PHP reports, so a version floor in
composer.jsonwould also reject patched servers.
- ⚠️ Run a PHP build that carries the PHP2Shell fixes (CVE-2026-109618 / CVE-2026-109619) — 8.3.36, 8.4.27, 8.5.12 or later, or your distribution's patched package. The SDK cannot protect an unpatched interpreter, and Composer cannot enforce this for you: distributions backport the fix without changing the version PHP reports, so a version floor in
- The
curlextension —composer.jsonrequiresext-curl(1.5.0and earlier did not). Without it Guzzle falls back to PHP's stream wrapper, which reads a response header line of any length. ⚠️ Composer checks extensions when it installs, not at run time (its defaultplatform-checkcovers only the PHP version), so without the extension a barecomposer require xzawed/keycloak-sdkstays on1.5.0. - Keycloak server 26.6.x (verified by the integration suite)
Install
The SDK is developed in the php/ directory of a polyglot monorepo, and Packagist cannot install from a subdirectory. Releases are therefore subtree-split into the dedicated read-only repository xzawed/keycloak-sdk-php, which is what Packagist reads — the package name stays xzawed/keycloak-sdk:
composer require "xzawed/keycloak-sdk:^1.0"
use Xzawed\Keycloak\{KeycloakClient, KeycloakConfig}; // admin lives under Xzawed\Keycloak\Admin
Quickstart
KeycloakClient::create() assembles auth immediately (no network); admin() is created lazily on first call and needs a client secret. Value types are final readonly class, and failures throw the KeycloakException hierarchy.
<?php declare(strict_types=1); require __DIR__ . '/vendor/autoload.php'; use Fschmtt\Keycloak\Representation\User; use Xzawed\Keycloak\KeycloakClient; use Xzawed\Keycloak\KeycloakConfig; $client = KeycloakClient::create(new KeycloakConfig( serverUrl: 'https://kc.example.com', realm: 'myrealm', clientId: 'my-app', clientSecret: '…', // load from an env var / secret manager; __toString is auto-masked )); // 1) client-credentials grant. TokenSet::__toString() masks the tokens (accessToken=***). $token = $client->auth()->clientCredentialsToken(); echo "token type: {$token->tokenType}, expires in: {$token->expiresIn}s\n"; // 2) hardened verification (alg pinning · exact iss · aud containment · mandatory exp · clock skew). $validated = $client->auth()->validate($token->accessToken); echo "subject: {$validated->subject}, issuer: {$validated->issuer}\n"; // 3) admin API — create/update return void (sister-language isomorphism). Look the id up with findIdByUsername(). $users = $client->admin()->users(); $users->create(new User(username: 'alice', enabled: true)); $userId = $users->findIdByUsername('alice'); $users->update($userId, $users->get($userId)->withEmail('alice@example.com')); echo "created userId={$userId}\n";
Audience: validation requires the token's
audto containclientId. A stock realm does not put the client id in a client-credentials token'saud, so on a default realm either passexpectedAudience: 'my-api'(the audience your realm actually issues), or add an Audience protocol mapper to the client in Keycloak.
Admin failures surface as KeycloakNotFoundError / KeycloakConflictError / KeycloakForbiddenError (all carrying KeycloakAdminError::getStatusCode()), network failures as KeycloakTransportError. admin()->raw() is the escape hatch to the underlying typed client.
Security defaults
- Algorithm pinning — the accepted JWT signature algorithms are pinned (
RS256by default, configurable viasignatureAlgorithms:); the header-suppliedalg, includingnone, is never trusted. The SDK decodes the raw header segment itself to gate onalgbefore verification, becausefirebase/php-jwtonly fills its&$headersout-parameter after a successful decode. - Hardened claims — exact
issmatch,audcontainment check, mandatoryexp(a token without one is rejected), and a bounded clock skew (clockSkew:, default 30s). - Bounded token input —
validate()andvalidateIdToken()reject a token longer thanJwtValidator::JWT_MAX_BYTESwithTokenValidationErrorbefore splitting or decoding it (and without a JWKS request), and a token without exactly two dots before splitting it. Verification has to decode the header and payload before it can check the signature, so without that bound an attacker-chosen token made the validator allocate up to about 167 bytes per input byte, and a 768 KiB token ended the request with a fatal out-of-memory error under PHP's defaultmemory_limit— the kind of attacker-reachable fatal error that PHP2Shell (above) can turn into code execution on an unpatched runtime. - DoS-safe JWKS — a fetched key set is used for 300 seconds. The first validation after that fetches it again, and if that fetch fails the validation fails (
KeycloakTransportError): keys older than 300 seconds are never used, even during an IdP outage. Within those 300 seconds a refetch is triggered only by an unresolved key ID (rotation) and never by a bad signature, and is rate-limited byjwksMinRefetchSeconds:(default 30s) — so once the key set has been fetched, no volume of forged randomkids makes the SDK issue more than one JWKS request per interval on top of that 300-second refetch. 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 SDK never sleeps — it returns the error immediately, so retry pacing stays the caller's decision. ⚠️ Like the refetch gate itself, this state is per-instance: under classic PHP-FPM a fresh store is built per request, so the bound holds within one request. - OIDC nonce /
id_tokenreplay protection —createAuthorizationRequest()always issues a cryptographic nonce, puts it on the authorization URL, and returns it onAuthorizationRequest::$nonce. Pass that value as the optional third argument toexchangeCode()and the SDK fully validates theid_token(signature ·iss·aud·exp) before comparing the nonce claim. The id_token'saudmust containclientId(OIDC Core §3.1.3.7) —expectedAudiencenarrows access-token validation only, so overriding it does not break the exchange. Omit it and id_token validation is skipped (same opt-out as the other eight languages). - Secret handling —
KeycloakConfig,TokenSetandAuthorizationRequestmask secrets, tokens and the PKCE verifier fully (***, no prefix) in both__toString()andjson_encode()(all three implementJsonSerializable); TLS verification is on by default and both connect and read timeouts are always applied.
Two scope limits worth knowing. The JWKS cache and its rate limit are per-JwksStore in-memory state, so their reach follows your deployment model: under a long-running worker (Swoole, RoadRunner) they span requests, but under classic PHP-FPM every request builds a fresh store and the limit only binds within that one request. And masking covers this SDK's own __toString() and json_encode(), plus the engine dumps that honour __debugInfo() — var_dump(), print_r() and debug_zval_dump(). It does not cover var_export(), which reads the properties directly and prints the raw value. (⚠️ This line used to say var_dump() and print_r() printed the raw value too; __debugInfo() is implemented on the masked types and SECURITY.md already said so.) PHP also has no erasable string type, so the client secret lives in an ordinary string for its lifetime: masking is defence in depth, not a guarantee about your logs.
Upgrading from 0.1.0
One signature changed, and it is a fix rather than a rearrangement.
roles()->update(Role $role) became roles()->update(string $name, Role $role). The old form took only the representation, and the library underneath builds the request path out of $role->getName() — so the path and the body came from the same value and a rename could not be expressed. Measured on 0.1.0: asking to rename old-name to new-name sent PUT /roles/new-name with body {"name":"new-name"}, and the current name appeared nowhere in the request. Keycloak renames with PUT /{current name} carrying the new name in the body, so that request was not a rename — it was an update aimed at a role that does not exist yet.
// before — the one-argument form could only update a role in place $admin->roles()->update(new Role(name: 'reporting')); // now — address by the current name, put the new one in the body $admin->roles()->update('reporting', new Role(name: 'analytics')); // updating without renaming: repeat the name $admin->roles()->update('reporting', new Role(name: 'reporting', description: 'Read-only'));
The other eight language SDKs always took (name, representation); this brings PHP back in line with them. Nothing else changed — users(), clients(), realms() and groups() already took a separate identifier and are untouched.
Upgrading from 0.1.0-rc.1
0.1.0-rc.2 adds OIDC nonce so id_token replay can be detected, which changes two signatures. One of them can break your code:
new AuthorizationRequest(...)gains a requiredstring $nonce. If you construct this type by hand you will get aTypeErrorfor the missing argument. Let the SDK build it instead —$client->auth()->createAuthorizationRequest(...)returns a fully populated instance. Reading fields off the returned object is unaffected (a field was added, none removed).exchangeCode()gains an optional third argument. Two-argument calls keep working unchanged — but that path still does not verify theid_token. To get replay protection, pass the nonce you were given:exchangeCode($code, $verifier, $req->nonce).
Versioning and support
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 (php-semver-checker, judged on its report body), and a removal or an incompatible change fails the build. ⚠️ The gate compares the API surface. A change that leaves the surface identical but alters behaviour is not caught by it, so read the release notes before upgrading.
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.
Documentation
- 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
- Full PHP example
- Deploying a Keycloak server
- Security policy