xzawed / keycloak-sdk
Keycloak SDK for PHP — OIDC/OAuth2 authentication + Admin REST API, part of a nine-language polyglot SDK
Requires
- php: ^8.3
- firebase/php-jwt: ^7.1
- fschmtt/keycloak-rest-api-client-php: 0.42.0
- guzzlehttp/guzzle: ^7.9
- guzzlehttp/psr7: ^2.7
- 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
This package is auto-updated.
Last update: 2026-08-31 15:15:53 UTC
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.0.0is on Packagist —composer require xzawed/keycloak-sdkresolves1.0.0under Composer's defaultminimum-stability: stable.⚠️ 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) - 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). - DoS-safe JWKS — a refetch is triggered only by an unresolved key ID (rotation) and never by a bad signature, and is rate-limited by
jwksMinRefetchSeconds:(default 30s) — so no volume of forged randomkids makes the SDK issue more than one JWKS request per interval. - 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. Omit it and id_token validation is skipped (same opt-out as the other eight languages). - Secret handling —
KeycloakConfigandTokenSetmask secrets and tokens fully (***, no prefix) in their__toString(); 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() — PHP has no erasable string type, so the client secret lives in an ordinary string for its lifetime and 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 on the same day 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