idci/keycloak-security-bundle
IDCI KeycloakSecurityBundle integrates Keycloak with Symfony security. It provides an authenticator and user provider for OpenID Connect/OAuth2 SSO, token validation, role mapping, and user provisioning, helping secure Symfony applications with Keycloak authentication and authorization.
Reality check first: idci/keycloak-security-bundle is a Symfony bundle, not a Laravel package. It has no ServiceProvider, no config/*.php publish tag, and it wires itself into Symfony's Security component, DependencyInjection compiler passes, and firewall config. Dropping it into a Laravel app will not boot. As a Laravel developer you have exactly two realistic entry points:
league/oauth2-client — already a Composer dependency you can use directly in Laravel:
{server_url}/realms/{realm}/protocol/openid-connect/*sub, email, preferred_username, realm_access.roles, resource_access.{client}.roles)If you are in a Symfony codebase (or evaluating a port):
composer require idci/keycloak-security-bundle:^2.0
// config/bundles.php
IDCI\Bundle\KeycloakSecurityBundle\IDCIKeycloakSecurityBundle::class => ['all' => true],
# config/packages/idci_keycloak_security.yaml
idci_keycloak_security:
server_url: '%env(KEYCLOAK_SERVER_URL)%' # e.g. https://sso.example.com/auth
realm: '%env(KEYCLOAK_REALM)%'
client_id: '%env(KEYCLOAK_CLIENT_ID)%'
client_secret: '%env(KEYCLOAK_CLIENT_SECRET)%'
redirect_route: 'keycloak_callback' # see Gotchas: must match Keycloak exactly
login_route: 'keycloak_login'
logout_route: 'keycloak_logout'
Then add the guard to a firewall and register the callback route. The bundle ships its own controller/route for the callback in most versions — check the bundle's Resources/config/routing.xml before writing your own. As of v2.0.2 the docs got a refresh; read the README against the code before trusting it, but it's a better starting point than it was.
Where to look first:
DependencyInjection/Configuration.php — the config tree is the documentation. Even post-v2.0.2 docs, the config tree is the ground truth.Security/ — provider, token, and listener/authenticator classes. This is the whole bundle.Security/Logout/ (or the logout listener / event subscriber) — new in v2.0.2, this is where server-side session logout is handled. Read it before assuming it does what you expect.Resources/config/ — service definitions and routes; tells you what is auto-wired.First use case: protect /admin behind Keycloak, map a client role (admin) to ROLE_ADMIN, and read email/preferred_username off the token for display. That single flow exercises every part of the bundle.
First use case in Laravel terms: composer require league/oauth2-client, write a KeycloakProvider extends AbstractProvider, register a Socialite driver or a RequestGuard, add keycloak_id to users, and JIT-provision on first login. Budget a day; it is not a large surface area.
Authorization-code flow (the only flow the bundle really targets):
KeycloakToken → redirects to {server_url}/realms/{realm}/protocol/openid-connect/auth with client_id, redirect_uri, response_type=code, scope=openid, and a state.redirect_route with ?code=...&state=......./protocol/openid-connect/token, gets access_token, refresh_token, id_token..../protocol/openid-connect/userinfo) or decodes the ID token, builds a resource owner, and wraps it in a KeycloakToken stored in the session.Role mapping is the part that eats your afternoon. Keycloak returns roles without the ROLE_ prefix, and it splits them across realm_access.roles (global) and resource_access.{client_id}.roles (per-client). In the Symfony bundle this is config-driven; in a Laravel port you write it yourself:
protected function mapRoles(array $resourceOwner): array
{
$realm = data_get($resourceOwner, 'realm_access.roles', []);
$client = data_get($resourceOwner, "resource_access.{$this->clientId}.roles", []);
return collect([...$realm, ...$client])
->map(fn ($r) => str_starts_with($r, 'ROLE_') ? $r : 'ROLE_'.strtoupper($r))
->unique()
->values()
->all();
}
Local user linking. Never key users on email — Keycloak lets users change email, and email may be unverified or absent depending on the mapper. Store keycloak_id (the sub claim) as a unique column and match on that; use email only for initial provisioning.
User::firstOrCreate(
['keycloak_id' => $claims['sub']],
['name' => $claims['preferred_username'] ?? $claims['email'], 'email' => $claims['email']]
);
Guard/stateless split. The bundle's authenticator is stateful (session token). For Laravel APIs, don't reuse that assumption — validate the bearer access token against Keycloak's introspection endpoint (.../token/introspect) or verify the JWT signature against the realm JWKS (.../certs). Cache the JWKS; don't fetch per request.
Logout — three distinct things, don't conflate them:
.../protocol/openid-connect/logout with id_token_hint and post_logout_redirect_uri. Mirror in Laravel with a controller redirect or LogoutResponse.What "server-side session logout" means for you operationally (verify against the code, but this is the shape):
logout_token (a signed JWT with events: {"http://schemas.openid.net/event/backchannel-logout": {}}, sid, sub). The handler must validate it and invalidate matching local sessions. If your sessions live in files on a single host, fine; if you run multiple app servers, you need shared session storage (Redis/DB) or the invalidation only hits one node.sid/sub. If the bundle only stores the token and not an index, revocation may be best-effort. Read the implementation before promising single-logout to your security team.In a Laravel port, the equivalent: implement POST /auth/keycloak/backchannel-logout, verify the logout_token against the realm JWKS, check the events claim, and delete sessions/tokens for the sub/sid. Persist a keycloak_sid on your session or a sessions table row; laravel/session's file/redis driver can be purged by user ID if you index it.
Config mirroring. Keep realm/client/redirect in one place (config/keycloak.php + .env), and use the same values across local/staging/prod Keycloak clients so you don't discover a redirect URI mismatch in production. Give each environment its own Keycloak client rather than one shared client with wildcard redirects.
Client credentials. If you need machine-to-machine, the bundle won't help — that's a client_credentials grant, no user, no guard. Use league/oauth2-client's ClientCredentials provider or Guzzle directly.
Symfony-only. No Laravel integration exists. Anything you read in the README about firewalls, security.yaml, and access_control has no Laravel analogue — translate it mentally to middleware + guards.
The v2.0.2 logout feature is opt-in on the Keycloak side. You have to set the Backchannel logout URL on the client. It also does nothing useful on multi-node deployments without shared session storage. Treat "handles server-side session logout" as "has a handler", not "single logout works out of the box" — test it explicitly by killing the session from the Keycloak admin console while logged in, then hitting a protected route.
Docs improved in v2.0.2, but the config tree still wins. The README is now a better starting point, and the new session-logout section is worth reading before you go hunting in Security/. Still sanity-check config keys against DependencyInjection/Configuration.php; docs drift, config trees don't.
License is NOASSERTION. That is not a green light. Before commercial use, confirm the actual license in LICENSE/composer.json and get legal sign-off. This alone can disqualify the package for many teams.
Maintenance risk is real — but improved. v2.0.2 (current) followed a long gap; still a small package (sub-100 stars, negligible dependents). Symfony's Security component has had BC breaks across 6.x → 7.x (authenticator-based security replaced guards). Pin the bundle and your Symfony minor version, and be prepared to fork. Treat it as vendored reference code you keep an eye on, not a dependency you upgrade casually. The v2.0.2 logout feature means upstream is alive again — worth watching, not worth depending on blindly.
Redirect URI mismatch (Invalid parameter: redirect_uri) is the single most common failure. Keycloak requires an exact match — scheme, host, port, path, trailing slash. http://localhost:8000 and http://127.0.0.1:8000 are different clients as far as Keycloak is concerned.
state and PKCE. The bundle relies on league/oauth2-client for state. PKCE (code_challenge) support depends on the version of that library and whether the bundle sets it. For public clients (SPA/mobile), verify it — and if it's missing, don't put the client secret in the browser.
Token expiry vs session lifetime. The session may outlive the access token (default 5 minutes in Keycloak). If the bundle stores the token and never refreshes it, users get logged out or hit 401s mid-session. Either shorten the session, implement refresh via the refresh_token, or set a sensible access-token lifespan in the realm.
Realm roles vs client roles. Check both realm_access.roles and resource_access.{client}.roles. Reading only one is the classic "roles work for me but not for the other team" bug.
Groups and attributes are not returned by default. You must add a Keycloak protocol mapper (Group Membership, User Attribute) to the client — the bundle cannot conjure data Keycloak isn't sending.
SSL in local dev. Self-signed Keycloak certs will fail Guzzle verification. Fix the trust store; don't disable verification in code that reaches production. If the bundle exposes a verify option, gate it behind app()->environment('local').
Debugging checklist:
bin/console debug:config idci_keycloak_security and debug:firewall <name> show the effective config — faster than reading YAML.bin/console debug:router and debug:event-dispatcher — confirm the callback route and the logout listener are actually registered before debugging logic that never runs.LOGIN_ERROR, CODE_TO_TOKEN_ERROR, LOGOUT_ERROR, and backchannel logout delivery failures; it tells you whether the problem is client config or your code.dd($claims)) to see exactly which claims your mappers produce. Do this before writing role logic.Location header on the initial redirect shows the exact redirect_uri and state being sent.tail the app logs while triggering a logout from the Keycloak admin console. Silence means the backchannel URL isn't registered or isn't reachable from Keycloak.Extension points worth knowing (Symfony): swap the user provider for your own to control provisioning, override the redirect/login/logout routes via config, and decorate the provider service if you need to add scopes or custom headers. For logout behavior, the logout listener/handler is the seam — decorate it if you need to add your own session invalidation (e.g. a Redis purge or a sessions table delete in a hybrid app). In a Laravel port, the equivalent seams are the guard (Auth::extend), the user provider (Auth::provider), and a KeycloakClient service you can bind and fake in tests.
Testing. Fake the token endpoint and userinfo with Http::fake() (Laravel) or an HTTP mock (Symfony) and assert on: user created once on repeat logins, roles mapped correctly, unverified email rejected, and expired token handled. Add a fifth now: a valid logout_token invalidates the session, and an invalid/unsigned one does not. That fifth test is the one v2.0.2 introduces, and it's the one most likely to be silently broken by misconfiguration.
How can I help you explore Laravel packages today?