vizir/laravel-keycloak-web-guard
Laravel authentication guard that signs users in to your web app through Keycloak. It handles login, logout, token refresh, and role-based route protection, delegating identity to Keycloak via OpenID Connect with minimal configuration and no custom auth code to maintain.
Install and wire it up (5 commands).
composer require vizir/laravel-keycloak-web-guard socialiteproviders/keycloak
php artisan vendor:publish --provider="Vizir\KeycloakWebGuard\KeycloakWebGuardServiceProvider"
Then three config touch-points:
.env — point at your realm:KEYCLOAK_BASE_URL=https://sso.example.com
KEYCLOAK_REALM=myrealm
KEYCLOAK_CLIENT_ID=laravel-app
KEYCLOAK_CLIENT_SECRET=the-secret
KEYCLOAK_REDIRECT_URL=https://app.example.com/auth/keycloak/callback
KEYCLOAK_REALM_PUBLIC_KEY="MIIBIjANBgkqhki..." # realm Settings → Keys → RS256 public key
config/auth.php — add the guard next to your normal one, don't replace it:'guards' => [
'web' => ['driver' => 'session', 'provider' => 'users'],
'keycloak-web' => ['driver' => 'keycloak-web', 'provider' => 'users'],
],
app/Providers/EventServiceProvider.php (the package rides on Socialite's Keycloak driver, so this is mandatory and easy to forget):protected $listen = [
\SocialiteProviders\Manager\SocialiteWasCalled::class => [
\SocialiteProviders\Keycloak\KeycloakExtendSocialite::class.'@handle',
],
];
Register the package's login/callback/logout routes in routes/web.php:
Route::group(['middleware' => ['web']], function () {
KeycloakWeb::routes();
});
Where to look first: config/keycloak-web.php (published) and src/KeycloakWebGuard.php. The guard is small — read user(), validateToken() and decodeToken() once and the whole package stops being a black box.
First use case — put an internal admin area behind Keycloak realm roles without building a login form:
Route::middleware('auth:keycloak-web')->prefix('admin')->group(function () {
Route::get('/', [DashboardController::class, 'index']);
});
Hit /auth/keycloak/login, get bounced to Keycloak, come back authenticated. That's the whole loop.
Identity: two modes, pick deliberately.
Mode A — no local users table (load_user_from_token => true): the guard returns a GenericUser hydrated from the token claims. Fast, zero sync logic, but you have no users.id to foreign-key against.
Mode B — sync to Eloquent (the one you actually want for a real app): implement the package's user-provider contract and point config at it.
// config/keycloak-web.php
'load_user_from_token' => false,
'user_provider' => App\Support\KeycloakUserProvider::class,
class KeycloakUserProvider implements \Vizir\KeycloakWebGuard\Contracts\KeycloakWebUserProvider
{
public function retrieveById($id)
{
return User::firstOrCreate(
['keycloak_id' => $id],
['name' => ..., 'email' => ...] // from KeycloakWeb::getUser() claims
);
}
public function retrieveByToken($id, $token)
{
return $this->retrieveById($id);
}
}
(Check the interface in src/Contracts against your installed version before writing it — the signatures are stable but pinned to the tag you pulled.)
Authorization is claim-based, not Eloquent-based. Keycloak roles live in realm_access.roles and resource_access.{client}.roles inside the access token. Use the facade rather than re-parsing JWTs:
KeycloakWeb::hasRole('admin');
KeycloakWeb::hasAnyRole(['admin', 'manager']);
KeycloakWeb::hasAllRoles(['admin', 'auditor']);
KeycloakWeb::hasPermission('billing:write');
KeycloakWeb::getUserRoles();
Wrap those in Gates so controllers and Blade stay clean and you keep one place to test:
Gate::define('manage-billing', fn ($user) => \KeycloakWeb::hasRole('billing-manager'));
@can('manage-billing')
<a href="{{ route('billing.index') }}">Billing</a>
@endcan
Logout has to happen twice. auth()->logout() only kills the Laravel session — the Keycloak SSO cookie survives and the next login silently re-authenticates. Always redirect through the IdP:
public function logout()
{
auth('keycloak-web')->logout(); // clear local session + token
return redirect(\KeycloakWeb::getLogoutUrl()); // kills the SSO session
}
Coexistence with the stock web guard. Keep public marketing pages on web and only mount auth:keycloak-web on internal routes. Don't make keycloak-web the default guard — anything calling auth()->user() in shared middleware will suddenly expect an SSO token.
Testing. Mock the facade instead of fabricating signed JWTs:
KeycloakWeb::shouldReceive('hasRole')->with('admin')->andReturn(true);
KeycloakWeb::shouldReceive('getUser')->andReturn(['sub' => 'abc', 'email' => '[email protected]']);
For feature tests of controllers, set config(['keycloak-web.load_user_from_token' => true]) and stub hasRole/hasAnyRole — that avoids needing a running Keycloak in CI.
Archived repo — treat it as frozen. Vizir set it read-only; there will be no Laravel 12/13 compatibility fixes. Pin the version, and for anything long-lived budget an exit path: a thin custom guard over socialiteproviders/keycloak, or robsontenorio/laravel-keycloak-guard (actively maintained, but it's a token/bearer guard — different shape; it will not do the browser authorization-code flow for you). Don't pick this package for a greenfield build without accepting that trade.
No token refresh. The access token is stored in the session and validated locally; when exp passes the user is bounced back through login. Keycloak's default client access-token lifespan is 5 minutes — leave it there and your users get logged out every five minutes. Fix it in Keycloak (Clients → Advanced → Access Token Lifespan), not in Laravel. Add a small leeway in config for clock skew across containers.
realm_public_key is not optional. Signature verification depends on it; if you blank it you're trusting unverified claims. It's the realm's RS256 public key, not the client secret, and it rotates when Keycloak rotates realm keys — a silent source of "everyone logged out at 3am".
Roles missing from the token is the #1 support question. If hasRole('admin') is always false, decode the access token at jwt.io: if realm_access is absent, the client scope doesn't carry roles. Add a "realm roles" / "client roles" mapper to the client scope in Keycloak. Also confirm you're reading the access token, not the ID token — the package keys off the former.
Redirect URI must match byte-for-byte including scheme, port and trailing slash, in both KEYCLOAK_REDIRECT_URL and the client's "Valid Redirect URIs". localhost vs 127.0.0.1 counts as a mismatch. Behind a reverse proxy, make sure TrustProxies is configured or you'll generate http:// callbacks from https:// requests.
Session coupling. The token lives in the session, so the session cookie lifetime is your real upper bound — check config/session.php lifetime and expire_on_close. Also: any cache/php artisan optimize change to config requires re-reading the published keycloak-web.php, since env() calls there go dead once config is cached.
Debugging checklist, in order: KeycloakWeb::getToken() dumps the raw JWT → decode the payload → confirm exp, iss (must equal {base_url}/realms/{realm}), aud, and role claims. Then check storage/logs/laravel.log for the package's unauthorized/forbidden exceptions; the guard throws rather than returns null, so a 401 on a protected route usually means an expired or unverifiable token, not a middleware ordering bug.
Extension points worth knowing: the user_provider config (your Eloquent sync hook), overriding the package's registered route URIs in routes.*, and default_roles for roles every authenticated user should implicitly hold. If you need refresh tokens or token exchange, fork the service provider rather than fighting the guard — the surface area is ~500 lines.
How can I help you explore Laravel packages today?