Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Keycloak Web Guard Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. .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
  1. 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'],
],
  1. Socialite listener in 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.

Implementation Patterns

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.

Gotchas and Tips

  • 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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console