bannerstop / keycloak
Framework-agnostic Keycloak / OpenID Connect client: login with PKCE, logout, token verification against JWKS, role mapping and the admin user directory
Requires
- php: ^8.5
- ext-json: *
- ext-openssl: *
- psr/clock: ^1.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
- psr/simple-cache: ^2.0 || ^3.0
Requires (Dev)
- nyholm/psr7: ^1.8.2
- phpunit/phpunit: ^12.0
Suggests
- ext-sodium: To verify EdDSA (Ed25519) signed tokens
- bannerstop/keycloak-bundle: Symfony integration
- bannerstop/keycloak-laravel: Laravel integration
- guzzlehttp/guzzle: A PSR-18 HTTP client (^7.0)
- php-http/guzzle6-adapter: PSR-18 adapter for projects that are stuck on Guzzle 6
- symfony/http-client: A PSR-18 HTTP client (Psr18Client)
Provides
None
Conflicts
None
Replaces
None
- dev-main
- 10.x-dev
- v10.1.0
- v10.0.1
- v10.0.0
- 9.x-dev
- v9.1.0
- v9.0.1
- v9.0.0
- 8.x-dev
- v8.1.0
- v8.0.1
- v8.0.0
- 7.x-dev
- v7.1.0
- v7.0.1
- v7.0.0
- 6.x-dev
- v6.1.0
- v6.0.1
- v6.0.0
- 5.x-dev
- v5.1.0
- v5.0.1
- v5.0.0
- 4.x-dev
- v4.1.0
- v4.0.1
- v4.0.0
- 3.x-dev
- v3.1.0
- v3.0.1
- v3.0.0
- 2.x-dev
- v2.1.0
- v2.0.1
- v2.0.0
- 1.x-dev
- v1.1.0
- v1.0.1
- v1.0.0
This package is auto-updated.
Last update: 2026-10-08 15:48:30 UTC
README
A small, framework-agnostic Keycloak / OpenID Connect client for PHP. It does the security-critical parts once and in one place:
- Browser login: authorization code flow with PKCE, state and nonce
- Token verification against the realm's JWKS: signature, issuer, audience, expiry
- Bearer tokens for APIs
- Logout (RP-initiated), refresh and revocation
- Role mapping from realm roles, client roles and groups to your application's roles
- User directory through the admin REST API and a service account
It only depends on PSR interfaces, so it runs with any HTTP client and inside any framework. Ready-made integrations:
- Symfony: bannerstop/keycloak-bundle
- Laravel: bannerstop/keycloak-laravel
Versions
Each major version targets one minimum PHP version and uses the language
features that come with it. Pick the highest major your PHP version allows;
Composer does this for you with a * or a wide constraint.
| Version | PHP |
|---|---|
| 1.x | ≥ 7.1.3 |
| 2.x | ≥ 7.2 |
| 3.x | ≥ 7.3 |
| 4.x | ≥ 7.4 |
| 5.x | ≥ 8.0 |
| 6.x | ≥ 8.1 |
| 7.x | ≥ 8.2 |
| 8.x | ≥ 8.3 |
| 9.x | ≥ 8.4 |
| 10.x | ≥ 8.5 |
Installation
composer require bannerstop/keycloak
You also need a PSR-18 HTTP client and PSR-17 factories, for example:
composer require guzzlehttp/guzzle # Guzzle 7, ships both composer require symfony/http-client nyholm/psr7 # Symfony HttpClient composer require php-http/guzzle6-adapter # projects stuck on Guzzle 6
Keycloak setup
- Create an OpenID Connect client with Client authentication on (confidential) and the Standard flow enabled.
- Add your callback URL to Valid redirect URIs and your logout target to Valid post logout redirect URIs.
- Under Advanced, set Proof Key for Code Exchange Code Challenge Method to
S256. - Optional:
- Groups: add a Group Membership mapper (claim
groups) to the client's dedicated scope. - APIs: add an Audience mapper so that access tokens carry the API's audience.
- Directory: enable Service accounts roles and assign the client role
realm-management→view-usersto the service account.
- Groups: add a Group Membership mapper (claim
Usage
Create the client
use Bannerstop\Keycloak\KeycloakClient; use Bannerstop\Keycloak\KeycloakConfig; use GuzzleHttp\Client; use GuzzleHttp\Psr7\HttpFactory; $config = new KeycloakConfig( 'https://sso.example.com', // server URL 'example', // realm 'my-app', // client id getenv('KEYCLOAK_CLIENT_SECRET') // null for public clients ); $factory = new HttpFactory(); $keycloak = new KeycloakClient($config, new Client(['timeout' => 10]), $factory, $factory, $psr16Cache);
Pass a PSR-16 cache (the 5th argument) in production: the discovery document and the signing keys are then fetched once per hour instead of once per request. After a key rotation, the new key is picked up automatically.
Browser login
use Bannerstop\Keycloak\Exception\LoginException; use Bannerstop\Keycloak\Login\LoginFlow; use Bannerstop\Keycloak\Login\NativeSessionStateStore; use Bannerstop\Keycloak\Login\RedirectTarget; use Bannerstop\Keycloak\Policy\EmailDomainPolicy; session_start(); $flow = new LoginFlow($keycloak, new NativeSessionStateStore(), [new EmailDomainPolicy(['example.com'])]); // login.php header('Location: ' . $flow->start('https://app.example.com/callback.php', $_GET['return_to'] ?? null)); // callback.php try { $result = $flow->finish($_GET); } catch (LoginException $exception) { // $exception->getReason() is a LoginFailure: StateMismatch, Cancelled, ProviderError, InvalidToken, NotAllowed } session_regenerate_id(true); $identity = $result->getIdentity(); $_SESSION['user'] = $identity->getSubject(); // stable id, use it to link accounts $_SESSION['tokens'] = $result->getTokens()->toArray(); $target = RedirectTarget::isLocal($result->getReturnTo()) ? $result->getReturnTo() : '/'; header('Location: ' . $target);
Identity offers getSubject(), getEmail(), isEmailVerified(),
getDisplayName(), getUsername(), getRealmRoles(), getClientRoles($clientId),
getGroups() and getClaims() for everything else.
Logout
use Bannerstop\Keycloak\Token\TokenSet; $tokens = TokenSet::fromArray($_SESSION['tokens']); session_destroy(); header('Location: ' . $keycloak->getLogoutUrl('https://app.example.com/', $tokens->getIdToken()));
Roles
Only roles and groups you map explicitly are granted:
use Bannerstop\Keycloak\Role\RoleMapper; $roles = RoleMapper::fromArray([ 'default_roles' => ['ROLE_USER'], 'realm_roles' => ['admin' => ['ROLE_ADMIN']], 'client_roles' => ['my-app' => ['editor' => ['ROLE_EDITOR']]], 'groups' => ['/staff/it' => ['ROLE_IT']], ])->map($identity);
Bearer tokens for APIs
use Bannerstop\Keycloak\Bearer\BearerToken; use Bannerstop\Keycloak\Exception\InvalidTokenException; $token = BearerToken::fromAuthorizationHeader($_SERVER['HTTP_AUTHORIZATION'] ?? null); try { $identity = $keycloak->verifyAccessToken((string) $token, 'my-api'); // audience } catch (InvalidTokenException $exception) { http_response_code(401); exit; }
Ending sessions with Keycloak
Logging out of Keycloak, or of another application, does not end the session of your application by itself. Two mechanisms close that gap; use both.
Back-channel logout (OpenID Connect Back-Channel Logout 1.0): Keycloak posts a signed logout token to your application when a session ends. Set the client's Backchannel logout URL and turn on Backchannel logout session required, then record the token:
use Bannerstop\Keycloak\Exception\KeycloakException; use Bannerstop\Keycloak\Session\SessionRevocations; // POST /keycloak/backchannel-logout - no session, no CSRF token $revocations = new SessionRevocations($psr16Cache, 8 * 3600); // shared by all web servers try { $accepted = $revocations->revoke($keycloak->verifyLogoutToken((string) ($_POST['logout_token'] ?? ''))); } catch (KeycloakException $exception) { $accepted = false; } http_response_code($accepted ? 200 : 400); // false also means: replayed token
Session check: keep a KeycloakSession next to your login and check it on
every request. It ends sessions that Keycloak revoked through the back channel,
and every $interval seconds it redeems the refresh token, which fails once the
Keycloak session is gone (logout elsewhere, user disabled, SSO session
expired). If Keycloak is unreachable, the session is kept.
use Bannerstop\Keycloak\Session\KeycloakSession; use Bannerstop\Keycloak\Session\SessionCheck; // after the login $_SESSION['keycloak'] = KeycloakSession::fromLogin($result, $keycloak->now())->toArray(); // on every request $session = KeycloakSession::fromArray($_SESSION['keycloak'] ?? []); $checked = null === $session ? null : (new SessionCheck($keycloak, $revocations, 300))->check($session); if (null === $checked) { // log the user out } else { $_SESSION['keycloak'] = $checked->toArray(); }
Keycloak does not always send a back-channel call for every session (e.g. when an administrator signs a user out of all sessions), so keep the interval check switched on as a safety net.
User directory
use Bannerstop\Keycloak\Admin\UserDirectory; foreach ((new UserDirectory($keycloak))->users() as $user) { // $user->getId() equals the "sub" of the user's tokens }
Security
See SECURITY.md for what the library checks, what is left to you, and how to report a vulnerability.
Development
composer install vendor/bin/phpunit
tests-e2e/ runs the library against a real Keycloak in Docker, see its README.
License
MIT, see LICENSE.