Laravel Authentication with Keycloak: Complete Tutorial
Last updated: March 2026
Laravel provides a robust authentication system out of the box, but it assumes you are managing users and credentials within your application. When you need centralized identity management across multiple applications, single sign-on, enterprise federation with LDAP or SAML, or multi-factor authentication without building it yourself, a dedicated identity provider like Keycloak is the right approach.
Keycloak integrates with Laravel through OAuth 2.0 / OpenID Connect. This guide covers two integration approaches: using Laravel Socialite for a quick setup, and implementing a custom authentication guard for deeper control. Both approaches handle login, logout, role-based authorization mapped to Laravel’s gates and policies, and JWT token validation.
Prerequisites
- PHP 8.2+ with Composer
- Laravel 11+ (fresh or existing project)
- A running Keycloak instance (version 22+). Use the Skycloak Docker Compose Generator or a managed instance from Skycloak.
Start a local Keycloak:
docker run -p 8080:8080
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin
quay.io/keycloak/keycloak:26.0 start-dev
Keycloak Configuration
Create a Realm and Client
- Log into
http://localhost:8080/admin - Create a realm called
laravel-app - Go to Clients > Create client:
- Client type: OpenID Connect
- Client ID:
laravel-backend
- Capability Config:
- Client authentication: On (confidential client)
- Standard flow: Enabled
- Direct access grants: Disabled
- Login Settings:
- Valid redirect URIs:
http://localhost:8000/auth/callback - Valid post logout redirect URIs:
http://localhost:8000/* - Web origins:
http://localhost:8000
- Valid redirect URIs:
- Go to the Credentials tab and copy the Client secret
Create Roles and Users
- Go to Realm roles > create
user,editor, andadminroles - Go to Users > create a test user with a password
- Assign the
userrole under Role mappings
Approach 1: Laravel Socialite
Socialite is Laravel’s official package for OAuth authentication. It is the quickest way to add Keycloak login.
Install Dependencies
composer require laravel/socialite
composer require socialiteproviders/keycloak
Configure the Provider
Add Keycloak credentials to .env:
KEYCLOAK_CLIENT_ID=laravel-backend
KEYCLOAK_CLIENT_SECRET=your-client-secret
KEYCLOAK_BASE_URL=http://localhost:8080
KEYCLOAK_REALM=laravel-app
KEYCLOAK_REDIRECT_URI=http://localhost:8000/auth/callback
Add the Keycloak configuration to config/services.php:
'keycloak' => [
'client_id' => env('KEYCLOAK_CLIENT_ID'),
'client_secret' => env('KEYCLOAK_CLIENT_SECRET'),
'base_url' => env('KEYCLOAK_BASE_URL'),
'realms' => env('KEYCLOAK_REALM', 'master'),
'redirect' => env('KEYCLOAK_REDIRECT_URI'),
],
Register the Socialite Provider
Add the event listener in app/Providers/AppServiceProvider.php:
<?php
namespace AppProviders;
use IlluminateSupportServiceProvider;
use LaravelSocialiteContractsFactory as SocialiteFactory;
use SocialiteProvidersKeycloakKeycloakExtendSocialite;
use SocialiteProvidersManagerSocialiteWasCalled;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->app['events']->listen(
SocialiteWasCalled::class,
KeycloakExtendSocialite::class . '@handle'
);
}
}
Create the Authentication Controller
Create app/Http/Controllers/AuthController.php:
<?php
namespace AppHttpControllers;
use AppModelsUser;
use IlluminateHttpRedirectResponse;
use IlluminateHttpRequest;
use IlluminateSupportFacadesAuth;
use IlluminateSupportStr;
use LaravelSocialiteFacadesSocialite;
class AuthController extends Controller
{
/**
* Redirect to Keycloak login page.
*/
public function redirect(): RedirectResponse
{
return Socialite::driver('keycloak')
->scopes(['openid', 'profile', 'email'])
->redirect();
}
/**
* Handle the callback from Keycloak.
*/
public function callback(): RedirectResponse
{
try {
$keycloakUser = Socialite::driver('keycloak')->user();
} catch (Exception $e) {
return redirect('/login')
->with('error', 'Authentication failed: ' . $e->getMessage());
}
// Find or create the local user
$user = User::updateOrCreate(
['keycloak_id' => $keycloakUser->getId()],
[
'name' => $keycloakUser->getName(),
'email' => $keycloakUser->getEmail(),
'keycloak_id' => $keycloakUser->getId(),
'password' => bcrypt(Str::random(32)),
'access_token' => $keycloakUser->token,
'refresh_token' => $keycloakUser->refreshToken,
]
);
// Sync roles from Keycloak
$this->syncRoles($user, $keycloakUser->token);
Auth::login($user, true);
return redirect()->intended('/dashboard');
}
/**
* Log the user out and end the Keycloak session.
*/
public function logout(Request $request): RedirectResponse
{
$idToken = $request->user()?->id_token;
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
// Redirect to Keycloak logout endpoint
$baseUrl = config('services.keycloak.base_url');
$realm = config('services.keycloak.realms');
$redirectUri = urlencode(config('app.url'));
$logoutUrl = "{$baseUrl}/realms/{$realm}/protocol/openid-connect/logout"
. "?post_logout_redirect_uri={$redirectUri}"
. "&client_id=" . config('services.keycloak.client_id');
return redirect($logoutUrl);
}
/**
* Sync Keycloak roles to the local user.
*/
private function syncRoles(User $user, string $accessToken): void
{
try {
$tokenParts = explode('.', $accessToken);
$payload = json_decode(
base64_decode(strtr($tokenParts[1], '-_', '+/')),
true
);
$roles = $payload['realm_access']['roles'] ?? [];
// Filter to application-relevant roles
$appRoles = array_intersect(
$roles,
['user', 'editor', 'admin']
);
$user->syncKeycloakRoles($appRoles);
} catch (Exception $e) {
logger()->warning(
'Failed to sync Keycloak roles',
['error' => $e->getMessage()]
);
}
}
}
Use the JWT Token Analyzer to inspect the access token and verify that roles appear in the realm_access.roles claim.
Update the User Model
Add Keycloak-specific fields and role methods to app/Models/User.php:
<?php
namespace AppModels;
use IlluminateDatabaseEloquentFactoriesHasFactory;
use IlluminateFoundationAuthUser as Authenticatable;
use IlluminateNotificationsNotifiable;
class User extends Authenticatable
{
use HasFactory, Notifiable;
protected $fillable = [
'name',
'email',
'password',
'keycloak_id',
'access_token',
'refresh_token',
'keycloak_roles',
];
protected $hidden = [
'password',
'remember_token',
'access_token',
'refresh_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'keycloak_roles' => 'array',
];
}
/**
* Check if the user has a specific Keycloak role.
*/
public function hasKeycloakRole(string $role): bool
{
return in_array($role, $this->keycloak_roles ?? []);
}
/**
* Check if the user has any of the given roles.
*/
public function hasAnyKeycloakRole(array $roles): bool
{
return !empty(array_intersect($roles, $this->keycloak_roles ?? []));
}
/**
* Sync roles from Keycloak.
*/
public function syncKeycloakRoles(array $roles): void
{
$this->update(['keycloak_roles' => array_values($roles)]);
}
/**
* Check if the user is an admin.
*/
public function isAdmin(): bool
{
return $this->hasKeycloakRole('admin');
}
}
Database Migration
Create a migration to add Keycloak fields:
php artisan make:migration add_keycloak_fields_to_users_table
<?php
use IlluminateDatabaseMigrationsMigration;
use IlluminateDatabaseSchemaBlueprint;
use IlluminateSupportFacadesSchema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('keycloak_id')->nullable()->unique()->after('id');
$table->text('access_token')->nullable()->after('password');
$table->text('refresh_token')->nullable()->after('access_token');
$table->json('keycloak_roles')->nullable()->after('refresh_token');
$table->string('password')->nullable()->change();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'keycloak_id',
'access_token',
'refresh_token',
'keycloak_roles',
]);
});
}
};
Run the migration:
php artisan migrate
Routes
Add auth routes to routes/web.php:
use AppHttpControllersAuthController;
Route::get('/login', fn () => view('login'))->name('login');
Route::get('/auth/redirect', [AuthController::class, 'redirect'])
->name('auth.redirect');
Route::get('/auth/callback', [AuthController::class, 'callback'])
->name('auth.callback');
Route::post('/logout', [AuthController::class, 'logout'])
->name('logout')
->middleware('auth');
Route::middleware('auth')->group(function () {
Route::get('/dashboard', fn () => view('dashboard'));
Route::get('/profile', fn () => view('profile'));
});
Approach 2: Custom Guard with JWT Validation
For API-first applications or when you need stateless authentication, implement a custom guard that validates Keycloak JWTs directly.
Install JWT Dependencies
composer require firebase/php-jwt
Create the Keycloak Guard
Create app/Auth/KeycloakGuard.php:
<?php
namespace AppAuth;
use AppModelsUser;
use FirebaseJWTJWK;
use FirebaseJWTJWT;
use IlluminateAuthGuardHelpers;
use IlluminateContractsAuthGuard;
use IlluminateHttpRequest;
use IlluminateSupportFacadesCache;
use IlluminateSupportFacadesHttp;
class KeycloakGuard implements Guard
{
use GuardHelpers;
private Request $request;
private ?array $decodedToken = null;
public function __construct(Request $request)
{
$this->request = $request;
}
/**
* Get the currently authenticated user.
*/
public function user(): ?User
{
if ($this->user !== null) {
return $this->user;
}
$token = $this->getTokenFromRequest();
if (!$token) {
return null;
}
try {
$this->decodedToken = $this->validateToken($token);
} catch (Exception $e) {
logger()->debug('Token validation failed: ' . $e->getMessage());
return null;
}
// Find or create user from token claims
$this->user = User::updateOrCreate(
['keycloak_id' => $this->decodedToken['sub']],
[
'name' => $this->decodedToken['name'] ?? '',
'email' => $this->decodedToken['email'] ?? '',
'keycloak_roles' => $this->decodedToken['realm_access']['roles'] ?? [],
'password' => bcrypt(Str::random(32)),
]
);
return $this->user;
}
/**
* Validate the user's credentials.
*/
public function validate(array $credentials = []): bool
{
$token = $credentials['token'] ?? null;
if (!$token) {
return false;
}
try {
$this->validateToken($token);
return true;
} catch (Exception) {
return false;
}
}
/**
* Extract the Bearer token from the request.
*/
private function getTokenFromRequest(): ?string
{
$header = $this->request->header('Authorization');
if (!$header || !str_starts_with($header, 'Bearer ')) {
return null;
}
return substr($header, 7);
}
/**
* Validate a Keycloak JWT token.
*/
private function validateToken(string $token): array
{
$keys = $this->getJWKS();
$decoded = JWT::decode($token, JWK::parseKeySet($keys));
$payload = (array) $decoded;
// Verify issuer
$expectedIssuer = config('services.keycloak.base_url')
. '/realms/' . config('services.keycloak.realms');
if (($payload['iss'] ?? '') !== $expectedIssuer) {
throw new Exception('Invalid token issuer');
}
return $payload;
}
/**
* Fetch and cache Keycloak's JWKS.
*/
private function getJWKS(): array
{
return Cache::remember('keycloak_jwks', 3600, function () {
$baseUrl = config('services.keycloak.base_url');
$realm = config('services.keycloak.realms');
$url = "{$baseUrl}/realms/{$realm}/protocol/openid-connect/certs";
$response = Http::get($url);
if (!$response->successful()) {
throw new Exception('Failed to fetch JWKS from Keycloak');
}
return $response->json();
});
}
/**
* Get the decoded token payload.
*/
public function getDecodedToken(): ?array
{
return $this->decodedToken;
}
}
Register the Guard
Add the guard to config/auth.php:
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'keycloak' => [
'driver' => 'keycloak',
'provider' => 'users',
],
],
Register the guard driver in app/Providers/AppServiceProvider.php:
use AppAuthKeycloakGuard;
use IlluminateSupportFacadesAuth;
public function boot(): void
{
Auth::extend('keycloak', function ($app, $name, array $config) {
return new KeycloakGuard($app['request']);
});
}
Use the Guard in API Routes
In routes/api.php:
use IlluminateSupportFacadesRoute;
Route::middleware('auth:keycloak')->group(function () {
Route::get('/user', function () {
return response()->json([
'user' => auth()->user(),
'roles' => auth()->user()->keycloak_roles,
]);
});
Route::get('/admin/users', function () {
return response()->json(['users' => User::all()]);
})->middleware('role:admin');
});
Role-Based Middleware
Create a middleware that checks Keycloak roles. Create app/Http/Middleware/KeycloakRole.php:
<?php
namespace AppHttpMiddleware;
use Closure;
use IlluminateHttpRequest;
use SymfonyComponentHttpFoundationResponse;
class KeycloakRole
{
public function handle(
Request $request,
Closure $next,
string ...$roles
): Response {
$user = $request->user();
if (!$user) {
return response()->json(
['error' => 'Unauthenticated'],
401
);
}
if (!$user->hasAnyKeycloakRole($roles)) {
return response()->json(
[
'error' => 'Insufficient permissions',
'required_roles' => $roles,
],
403
);
}
return $next($request);
}
}
Register the middleware alias in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'role' => AppHttpMiddlewareKeycloakRole::class,
]);
})
Use it in routes:
Route::middleware(['auth:keycloak', 'role:admin'])->group(function () {
Route::get('/admin/dashboard', [AdminController::class, 'index']);
});
Route::middleware(['auth:keycloak', 'role:editor,admin'])->group(function () {
Route::post('/articles', [ArticleController::class, 'store']);
});
If you get unexpected 403 responses, our 403 Forbidden troubleshooting guide covers all common causes, including realm vs client role confusion.
Gates and Policies
Map Keycloak roles to Laravel’s authorization system for fine-grained access control.
In app/Providers/AppServiceProvider.php:
use IlluminateSupportFacadesGate;
public function boot(): void
{
// Gate based on Keycloak roles
Gate::define('manage-users', function (User $user) {
return $user->hasKeycloakRole('admin');
});
Gate::define('edit-content', function (User $user) {
return $user->hasAnyKeycloakRole(['editor', 'admin']);
});
Gate::define('view-reports', function (User $user) {
return $user->hasAnyKeycloakRole(['admin', 'editor', 'user']);
});
}
Use gates in controllers:
public function index()
{
$this->authorize('manage-users');
return User::paginate(20);
}
Use gates in Blade templates:
@can('manage-users')
<a href="{{ route('admin.users') }}">Manage Users</a>
@endcan
@can('edit-content')
<a href="{{ route('articles.create') }}">Create Article</a>
@endcan
For a deeper understanding of authorization models in Keycloak, see our guide on fine-grained authorization and the RBAC features page.
Token Refresh
For the Socialite approach, implement token refresh by adding a method to the AuthController:
/**
* Refresh the Keycloak access token.
*/
public function refreshToken(Request $request): RedirectResponse|JsonResponse
{
$user = $request->user();
if (!$user?->refresh_token) {
return redirect('/login');
}
$baseUrl = config('services.keycloak.base_url');
$realm = config('services.keycloak.realms');
$tokenUrl = "{$baseUrl}/realms/{$realm}/protocol/openid-connect/token";
$response = Http::asForm()->post($tokenUrl, [
'grant_type' => 'refresh_token',
'refresh_token' => $user->refresh_token,
'client_id' => config('services.keycloak.client_id'),
'client_secret' => config('services.keycloak.client_secret'),
]);
if ($response->failed()) {
Auth::logout();
return redirect('/login')
->with('error', 'Session expired. Please log in again.');
}
$tokens = $response->json();
$user->update([
'access_token' => $tokens['access_token'],
'refresh_token' => $tokens['refresh_token'],
]);
// Re-sync roles from new token
$this->syncRoles($user, $tokens['access_token']);
return response()->json(['message' => 'Token refreshed']);
}
Add middleware that automatically refreshes expired tokens:
<?php
namespace AppHttpMiddleware;
use Closure;
use FirebaseJWTJWT;
use IlluminateHttpRequest;
class RefreshKeycloakToken
{
public function handle(Request $request, Closure $next)
{
$user = $request->user();
if ($user?->access_token) {
try {
$parts = explode('.', $user->access_token);
$payload = json_decode(base64_decode($parts[1]), true);
$expiresAt = $payload['exp'] ?? 0;
// Refresh if token expires within 60 seconds
if (time() > ($expiresAt - 60)) {
app(AuthController::class)->refreshToken($request);
}
} catch (Exception $e) {
logger()->debug('Token check failed: ' . $e->getMessage());
}
}
return $next($request);
}
}
Testing
Test your authentication flow:
# Start the development server
php artisan serve
# Test the API guard with a token from Keycloak
TOKEN=$(curl -s -X POST
http://localhost:8080/realms/laravel-app/protocol/openid-connect/token
-d "grant_type=password"
-d "client_id=laravel-backend"
-d "client_secret=your-secret"
-d "username=testuser"
-d "password=testpassword"
-d "scope=openid" | jq -r '.access_token')
# Call a protected endpoint
curl -H "Authorization: Bearer $TOKEN"
http://localhost:8000/api/user
Production Considerations
-
HTTPS: Always use HTTPS in production. Configure
FORCE_HTTPS=trueandSESSION_SECURE_COOKIE=truein.env. -
Session configuration: Set
SESSION_DOMAINto your application domain and useSESSION_SAME_SITE=lax. -
Cache JWKS: The custom guard caches JWKS for 1 hour. Adjust the TTL based on how frequently Keycloak rotates keys.
-
Monitor authentication: Enable Keycloak’s audit logging and use Skycloak’s insights for real-time monitoring.
-
SCIM provisioning: For automated user lifecycle management, integrate Keycloak’s SCIM support with your Laravel application. Test endpoints with the SCIM Endpoint Tester.
-
Session management: Configure session management policies in Keycloak to control session duration and concurrent session limits.
-
Consider managed Keycloak: Skycloak handles Keycloak infrastructure, updates, and security, letting your team focus on your Laravel application. Check our SLA for uptime guarantees.
Next Steps
- Add identity provider federation for social and enterprise logins
- Customize the Keycloak login page with brand theming
- Use the Keycloak Config Generator to automate realm setup
- Read the Keycloak securing applications guide for advanced patterns
- Explore our documentation for more integration guides
Try Skycloak
Focus on your Laravel application, not identity infrastructure. Skycloak provides fully managed Keycloak with automatic updates, backups, high availability, and expert support. See our pricing to get started.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.