careerum/laravel-keycloak-web-guard
A Laravel authentication guard for integrating Keycloak with the web guard. It lets you protect routes and authenticate users through Keycloak’s OpenID Connect/OAuth2 flow, with configuration and a guard driver for session-based login.
This package wires a Laravel app into Keycloak's OpenID Connect endpoints (discovery, authorization-code flow, token exchange/refresh, userinfo, logout). Minimum path to a working login:
composer require the package, then publish its config (php artisan vendor:publish --tag=keycloak-config or whatever the provider exposes) and inspect config/keycloak.php first — base URL, realm, client id/secret, redirect URI, and scopes all live there..env: KEYCLOAK_BASE_URL, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, KEYCLOAK_REDIRECT_URI. The redirect URI must match the Keycloak client's Valid Redirect URIs byte-for-byte (scheme, host, port, trailing slash).config/auth.php / bootstrap/app.php per its README, then protect a small route group and hit /login.If login fails before you've written any code, the problem is almost always discovery: check that {base}/realms/{realm}/.well-known/openid-configuration is reachable from the app container (not just your laptop).
Guard + middleware, not manual token juggling. Let the package own the redirect/callback/token cycle. Your controllers should only ever see an authenticated user object; reach for the raw token store only when calling a downstream API that needs a bearer token.
Token lifetime handling. Keycloak access tokens are short-lived (typically 5 minutes). Decide early whether you refresh silently in middleware or force re-auth on expiry; refresh-token rotation in Keycloak means a stale refresh token is a hard failure, so persist whatever the package hands back after each refresh rather than assuming the original refresh token stays valid.
Role mapping. Keycloak puts roles under realm_access.roles and resource_access.{client}.roles. Map these to Laravel abilities in a single place (a policy or a Gate::before) instead of sprinkling in_array() checks through controllers — realm roles and client roles are easy to confuse, and the distinction matters in multi-client realms.
Multi-realm / multi-tenant. Keep one config block per realm and resolve the guard dynamically, or run separate connections. Sharing a single discovery cache key across realms is a classic source of "wrong signing keys" bugs.
Discovery caching. Cache the well-known document (and JWKS) with a TTL measured in hours, but make the cache key include base URL + realm + a version salt so a Keycloak upgrade that rotates keys doesn't leave you validating against stale certs until the TTL expires.
Testing. The v3.4.0 test suite is PHPUnit/Testbench-based, so you can reuse its patterns: fake the HTTP layer (Http::fake() or a Guzzle MockHandler) and assert on the redirect URL, state/nonce handling, and the token exchange payload rather than hitting a live realm in CI. Mirror the package's own Guzzle 7 and 8 CI matrix if you extend it.
Upgrading to v3.4.0. Nothing to change in application code, but verify your lockfile resolves to Guzzle ^7.0 || ^8.0 and that no other dependency pins Guzzle 6. If you were on Guzzle 6, this is the release that forces the move.
Guzzle 6 is gone. The constraint is now ^7.0 || ^8.0. Any transitive dependency still requiring ^6.0 will make the install unsatisfiable; if you can't upgrade, pin the package to the previous major. Also confirm your PHP baseline satisfies Guzzle 8's minimum before letting Composer float you up a major — Guzzle 7 is the safe landing spot for older runtimes.
Logs are now deliberately less useful. As of v3.4.0, Keycloak HTTP failures log only the exception class, method, sanitized URL, query, fragment, and status — no headers, bodies, or Guzzle's exception message. That's the right call for Authorization, client_secret, codes, and tokens, but it means "why did the token exchange 400?" is no longer answerable from the log alone. Plan for it: correlate failures with a request ID you generate yourself, and when debugging locally, temporarily instrument the call in your own code rather than expecting the package to dump the payload.
Don't reintroduce the leak. If you add your own try/catch or Guzzle middleware around these calls, you'll re-log the very things this release scrubbed. Never Log::error($e->getMessage()) or json_encode($response->getBody()) on a token endpoint response.
URLs are sanitized by stripping userinfo. Credentials embedded in a base URL (https://user:pass@keycloak/...) will vanish from logs and, more importantly, are a bad idea — use the dedicated client secret config instead.
Network errors are handled now, but your handlers may not be. The fix covers the "no response at all" case (DNS failure, connection refused, timeout), where there is no status code. Any custom error handling you wrote against an earlier version may assume a response object exists — check for null before calling getStatusCode().
Discovery errors carry the original exception. getPrevious() now preserves the underlying cause, so report the full chain. A discovery failure almost always means the base URL is wrong or the realm name is mistyped — not that your client secret is bad, which is what most people check first.
Clock skew and state/nonce. If you ever see intermittent invalid_token or nonce mismatches, check NTP on both the app host and the Keycloak host before touching config. And if you're building the authorization URL yourself anywhere, generate a fresh state per request and validate it on callback — the package does this, but custom flows often don't.
Extension points. The service provider bindings and the discovery/token client are the places to override for custom caching backends, custom HTTP clients (e.g. adding retries or a proxy), or non-standard realms. Prefer decorating the package's client over forking it, so future security fixes like this one land without a merge.
How can I help you explore Laravel packages today?