Laravel Authentication with Keycloak: Complete Tutorial

Guilliano Molaire Guilliano Molaire Updated June 4, 2026 10 min read

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

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

  1. Log into http://localhost:8080/admin
  2. Create a realm called laravel-app
  3. Go to Clients > Create client:
    • Client type: OpenID Connect
    • Client ID: laravel-backend
  4. Capability Config:
    • Client authentication: On (confidential client)
    • Standard flow: Enabled
    • Direct access grants: Disabled
  5. Login Settings:
    • Valid redirect URIs: http://localhost:8000/auth/callback
    • Valid post logout redirect URIs: http://localhost:8000/*
    • Web origins: http://localhost:8000
  6. Go to the Credentials tab and copy the Client secret

Create Roles and Users

  1. Go to Realm roles > create user, editor, and admin roles
  2. Go to Users > create a test user with a password
  3. Assign the user role 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

  1. HTTPS: Always use HTTPS in production. Configure FORCE_HTTPS=true and SESSION_SECURE_COOKIE=true in .env.

  2. Session configuration: Set SESSION_DOMAIN to your application domain and use SESSION_SAME_SITE=lax.

  3. Cache JWKS: The custom guard caches JWKS for 1 hour. Adjust the TTL based on how frequently Keycloak rotates keys.

  4. Monitor authentication: Enable Keycloak’s audit logging and use Skycloak’s insights for real-time monitoring.

  5. SCIM provisioning: For automated user lifecycle management, integrate Keycloak’s SCIM support with your Laravel application. Test endpoints with the SCIM Endpoint Tester.

  6. Session management: Configure session management policies in Keycloak to control session duration and concurrent session limits.

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

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.

Guilliano Molaire
Written by Guilliano Molaire Founder

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

Ready to simplify your authentication?

Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.

© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman