Debug School

rakesh kumar
rakesh kumar

Posted on

Keycloak APIs in Laravel: Token, Introspect, Userinfo, Logout & Admin (JWT Included)

Keycloak API Endpoints
How to get acesstoken using api endpoints curl
Laravel code:to get access token in 3 ways
Complete authentication laravel code
Token-based SSO (Authorization header, NO cookies)
Token Introspection (check “active” server-side) using api endpoints curl
Userinfo (standard OIDC profile endpoint)
Logout (end session)
JWKS / Certs (public keys for JWT verification)
OpenID Provider configuration (discovery)
Authorization endpoint (browser login)
Token Exchange (if enabled)
Get admin token (for Admin API calls)
Users(list,search and create user and set password)
List and get clients
realm and client roles
Realm groups
Sessions / Logout user sessions (admin)
“Which JWT is used where?” quick mapping
Laravel code to get all api endpoints
Why this Laravel JWT verification code exists
When you NEED this Laravel JWT verification code
JWT verification (decode + verify signature via JWKS)

Keycloak

KC_BASE=http://localhost:8080

REALM=master
Enter fullscreen mode Exit fullscreen mode

Endpoints


Token: POST $KC_BASE/realms/$REALM/protocol/openid-connect/token

Introspect: POST $KC_BASE/realms/$REALM/protocol/openid-connect/token/introspect

Userinfo: GET $KC_BASE/realms/$REALM/protocol/openid-connect/userinfo

Logout: GET/POST $KC_BASE/realms/$REALM/protocol/openid-connect/logout

Admin: ... $KC_BASE/admin/realms/$REALM/...
Enter fullscreen mode Exit fullscreen mode

How to get acesstoken using api endpoints curl

Token (get tokens)
tokenByPassword

POST $KC_BASE/realms/$REALM/protocol/openid-connect/token
Enter fullscreen mode Exit fullscreen mode

Password grant (direct login)

curl -s -X POST "$KC_BASE/realms/$REALM/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=password" \
  -d "client_id=$CLIENT_ID" \
  -d "client_secret=$CLIENT_SECRET" \
  -d "username=$USERNAME" \
  -d "password=$PASSWORD"
Enter fullscreen mode Exit fullscreen mode

client_credentials (service account)
tokenByClientCredentials

curl -s -X POST "$KC_BASE/realms/$REALM/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=$CLIENT_ID" \
  -d "client_secret=$CLIENT_SECRET"
Enter fullscreen mode Exit fullscreen mode

refresh_token
tokenByRefreshToken

curl -s -X POST "$KC_BASE/realms/$REALM/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=refresh_token" \
  -d "client_id=$CLIENT_ID" \
  -d "client_secret=$CLIENT_SECRET" \
  -d "refresh_token=$REFRESH_TOKEN"
Enter fullscreen mode Exit fullscreen mode

JWT mapping

Response has: access_token (JWT), refresh_token (JWT), sometimes id_token (JWT, if OIDC login flow / scope includes openid).

In access_token you’ll commonly inspect:
iss, aud, azp, exp, iat, sub, scope, realm_access.roles, resource_access.{client}.roles.

https://chatgpt.com/c/693e3b14-8108-8321-b98b-ff1366d9fecc

Laravel code:to get access token in 3 ways

tokenByPassword
tokenByClientCredentials
tokenByRefreshToken

Laravel: Config you should keep in one place

config/services.php


'keycloak' => [
    'base_url'      => env('KEYCLOAK_BASE_URL', 'https://auth.example.com'),
    'realm'         => env('KEYCLOAK_REALM', 'master'),
    'client_id'     => env('KEYCLOAK_CLIENT_ID'),
    'client_secret' => env('KEYCLOAK_CLIENT_SECRET'), // keep null for public clients
],
Enter fullscreen mode Exit fullscreen mode

Laravel code: Token endpoint for each grant type

Create a service: app/Services/KeycloakOidcService.php

<?php

namespace App\Services;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class KeycloakOidcService
{
    private string $base;
    private string $realm;
    private string $clientId;
    private ?string $clientSecret;

    public function __construct()
    {
        $this->base         = rtrim(config('services.keycloak.base_url'), '/');
        $this->realm        = config('services.keycloak.realm');
        $this->clientId     = config('services.keycloak.client_id');
        $this->clientSecret = config('services.keycloak.client_secret');
    }

    private function tokenUrl(): string
    {
        return "{$this->base}/realms/{$this->realm}/protocol/openid-connect/token";
    }

    private function commonForm(): array
    {
        $form = ['client_id' => $this->clientId];

        // If confidential client, send secret
        if (!empty($this->clientSecret)) {
            $form['client_secret'] = $this->clientSecret;
        }

        return $form;
    }

    /**
     * A) Password Grant (direct login)
     */
    public function tokenByPassword(string $username, string $password, ?string $scope = null): array
    {
        $payload = array_merge($this->commonForm(), [
            'grant_type' => 'password',
            'username'   => $username,
            'password'   => $password,
        ]);

        if ($scope) $payload['scope'] = $scope;

        $res = Http::asForm()->post($this->tokenUrl(), $payload);

        if (!$res->successful()) {
            Log::warning('[KC] password token failed', ['status' => $res->status(), 'body' => $res->json()]);
            return ['ok' => false, 'status' => $res->status(), 'error' => $res->json()];
        }

        return ['ok' => true, 'data' => $res->json()];
    }

    /**
     * B) Client Credentials (service account)
     */
    public function tokenByClientCredentials(?string $scope = null): array
    {
        $payload = array_merge($this->commonForm(), [
            'grant_type' => 'client_credentials',
        ]);

        if ($scope) $payload['scope'] = $scope;

        $res = Http::asForm()->post($this->tokenUrl(), $payload);

        if (!$res->successful()) {
            Log::warning('[KC] client_credentials token failed', ['status' => $res->status(), 'body' => $res->json()]);
            return ['ok' => false, 'status' => $res->status(), 'error' => $res->json()];
        }

        return ['ok' => true, 'data' => $res->json()];
    }

    /**
     * C) Refresh Token
     */
    public function tokenByRefreshToken(string $refreshToken, ?string $scope = null): array
    {
        $payload = array_merge($this->commonForm(), [
            'grant_type'    => 'refresh_token',
            'refresh_token' => $refreshToken,
        ]);

        if ($scope) $payload['scope'] = $scope;

        $res = Http::asForm()->post($this->tokenUrl(), $payload);

        if (!$res->successful()) {
            Log::warning('[KC] refresh_token failed', ['status' => $res->status(), 'body' => $res->json()]);
            return ['ok' => false, 'status' => $res->status(), 'error' => $res->json()];
        }

        return ['ok' => true, 'data' => $res->json()];
    }
}
Enter fullscreen mode Exit fullscreen mode

Example Controller usage

public function loginWithPassword(Request $request, \App\Services\KeycloakOidcService $kc)
{
    $result = $kc->tokenByPassword($request->username, $request->password);

    if (!$result['ok']) {
        return response()->json(['status' => false, 'kc' => $result], 401);
    }

    return response()->json(['status' => true, 'tokens' => $result['data']]);
}
Enter fullscreen mode Exit fullscreen mode

Complete authentication laravel code

)

 .env
KEYCLOAK_BASE_URL=https://auth.motoshare.in
KEYCLOAK_REALM=motoshare
KEYCLOAK_CLIENT_ID=motoshare
KEYCLOAK_CLIENT_SECRET=xxxxxx   # keep empty if public client
Enter fullscreen mode Exit fullscreen mode

config/services.php

'keycloak' => [
    'base_url'      => env('KEYCLOAK_BASE_URL'),
    'realm'         => env('KEYCLOAK_REALM', 'master'),
    'client_id'     => env('KEYCLOAK_CLIENT_ID'),
    'client_secret' => env('KEYCLOAK_CLIENT_SECRET'), // null/empty for public
],
Enter fullscreen mode Exit fullscreen mode

Token Service (all 3 methods + userinfo)

app/Services/KeycloakOidcService.php

<?php

namespace App\Services;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class KeycloakOidcService
{
    private string $base;
    private string $realm;
    private string $clientId;
    private ?string $clientSecret;

    public function __construct()
    {
        $this->base         = rtrim(config('services.keycloak.base_url'), '/');
        $this->realm        = config('services.keycloak.realm');
        $this->clientId     = config('services.keycloak.client_id');
        $this->clientSecret = config('services.keycloak.client_secret') ?: null;
    }

    private function tokenUrl(): string
    {
        return "{$this->base}/realms/{$this->realm}/protocol/openid-connect/token";
    }

    private function userinfoUrl(): string
    {
        return "{$this->base}/realms/{$this->realm}/protocol/openid-connect/userinfo";
    }

    private function commonForm(): array
    {
        $form = ['client_id' => $this->clientId];
        if (!empty($this->clientSecret)) {
            $form['client_secret'] = $this->clientSecret;
        }
        return $form;
    }

    /** 1) FIRST LOGIN ONLY */
    public function tokenByPassword(string $username, string $password, ?string $scope = null): array
    {
        $payload = array_merge($this->commonForm(), [
            'grant_type' => 'password',
            'username'   => $username,
            'password'   => $password,
        ]);
        if ($scope) $payload['scope'] = $scope;

        $res = Http::asForm()->post($this->tokenUrl(), $payload);

        if (!$res->successful()) {
            Log::warning('[KC] password token failed', ['status' => $res->status(), 'body' => $res->json()]);
            return ['ok' => false, 'status' => $res->status(), 'error' => $res->json()];
        }

        return ['ok' => true, 'data' => $res->json()];
    }

    /** 2) SERVER-TO-SERVER (NO USER) */
    public function tokenByClientCredentials(?string $scope = null): array
    {
        $payload = array_merge($this->commonForm(), [
            'grant_type' => 'client_credentials',
        ]);
        if ($scope) $payload['scope'] = $scope;

        $res = Http::asForm()->post($this->tokenUrl(), $payload);

        if (!$res->successful()) {
            Log::warning('[KC] client_credentials token failed', ['status' => $res->status(), 'body' => $res->json()]);
            return ['ok' => false, 'status' => $res->status(), 'error' => $res->json()];
        }

        return ['ok' => true, 'data' => $res->json()];
    }

    /** 3) ACCESS TOKEN EXPIRED → USE REFRESH TOKEN */
    public function tokenByRefreshToken(string $refreshToken, ?string $scope = null): array
    {
        $payload = array_merge($this->commonForm(), [
            'grant_type'    => 'refresh_token',
            'refresh_token' => $refreshToken,
        ]);
        if ($scope) $payload['scope'] = $scope;

        $res = Http::asForm()->post($this->tokenUrl(), $payload);

        if (!$res->successful()) {
            Log::warning('[KC] refresh_token failed', ['status' => $res->status(), 'body' => $res->json()]);
            return ['ok' => false, 'status' => $res->status(), 'error' => $res->json()];
        }

        return ['ok' => true, 'data' => $res->json()];
    }

    /** Standard OIDC profile endpoint */
    public function userinfo(string $accessToken): array
    {
        $res = Http::withToken($accessToken)->get($this->userinfoUrl());

        if (!$res->successful()) {
            Log::warning('[KC] userinfo failed', ['status' => $res->status(), 'body' => $res->json()]);
            return ['ok' => false, 'status' => $res->status(), 'error' => $res->json()];
        }

        return ['ok' => true, 'data' => $res->json()];
    }
}
Enter fullscreen mode Exit fullscreen mode

Token Storage + Auto Refresh Helper

app/Services/KeycloakTokenStore.php


<?php

namespace App\Services;

use Illuminate\Support\Facades\Log;

class KeycloakTokenStore
{
    // store in session (simple). You can store in DB for mobile apps.
    public function saveTokens(array $tokens): void
    {
        // tokens: access_token, refresh_token, expires_in, refresh_expires_in, token_type
        session([
            'kc_access_token'  => $tokens['access_token'] ?? null,
            'kc_refresh_token' => $tokens['refresh_token'] ?? null,
            'kc_expires_at'    => time() + (int)($tokens['expires_in'] ?? 0),
        ]);
    }

 public function clear(): void
    {
        session()->forget([
            'kc_access_token',
            'kc_refresh_token',
            'kc_id_token',
            'kc_expires_at',
        ]);

        session()->invalidate();
        session()->regenerateToken();
    }

    public function accessToken(): ?string
    {
        return session('kc_access_token');
    }

    public function refreshToken(): ?string
    {
        return session('kc_refresh_token');
    }

    public function expiresAt(): int
    {
        return (int) session('kc_expires_at', 0);
    }

    /**
     * THIS decides WHEN to call tokenByRefreshToken()
     */
    public function getValidAccessToken(KeycloakOidcService $kc): ?string
    {
        $access  = $this->accessToken();
        $refresh = $this->refreshToken();
        $expAt   = $this->expiresAt();

        // if token exists and will not expire in next 30 seconds, use it
        if ($access && time() < ($expAt - 30)) {
            return $access;
        }

        // otherwise refresh if possible
        if ($refresh) {
            $res = $kc->tokenByRefreshToken($refresh);

            if ($res['ok']) {
                $data = $res['data'];

                // Some KC configs rotate refresh_token; if not, keep old one
                if (empty($data['refresh_token'])) {
                    $data['refresh_token'] = $refresh;
                }

                $this->saveTokens($data);
                return $data['access_token'] ?? null;
            }

            Log::warning('[KC] refresh failed; need login again', $res);
        }

        return null; // force re-login
    }
}
Enter fullscreen mode Exit fullscreen mode

Controller showing EXACT use cases

app/Http/Controllers/KeycloakAuthController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Services\KeycloakOidcService;
use App\Services\KeycloakTokenStore;

class KeycloakAuthController extends Controller
{
    /**
     * ✅ When to use tokenByPassword():
     * - user submits username/password FIRST TIME
     */
    public function loginWithPassword(Request $request, KeycloakOidcService $kc, KeycloakTokenStore $store)
    {
        $request->validate([
            'username' => 'required|string',
            'password' => 'required|string',
        ]);

        $result = $kc->tokenByPassword($request->username, $request->password);

        if (!$result['ok']) {
            return response()->json(['status' => false, 'kc' => $result], 401);
        }

        $store->saveTokens($result['data']);

        return response()->json([
            'status' => true,
            'message' => 'Logged in (password grant)',
            'access_token' => $result['data']['access_token'],
        ]);
    }

    /**
     * ✅ When to use tokenByRefreshToken():
     * - access token expired / about to expire
     * - do NOT ask password again
     */
    public function refresh(Request $request, KeycloakOidcService $kc, KeycloakTokenStore $store)
    {
        $refresh = $store->refreshToken();

        if (!$refresh) {
            return response()->json(['status' => false, 'message' => 'no_refresh_token'], 401);
        }

        $result = $kc->tokenByRefreshToken($refresh);

        if (!$result['ok']) {
            $store->clear();
            return response()->json(['status' => false, 'message' => 'refresh_failed', 'kc' => $result], 401);
        }

        // keep old refresh if KC did not rotate it
        $data = $result['data'];
        if (empty($data['refresh_token'])) {
            $data['refresh_token'] = $refresh;
        }

        $store->saveTokens($data);

        return response()->json([
            'status' => true,
            'message' => 'Token refreshed',
            'access_token' => $data['access_token'],
        ]);
    }

    /**
     * ✅ Example protected endpoint:
     * - uses access token if valid
     * - otherwise auto calls refresh internally
     * - then calls /userinfo to get profile
     */
    public function me(KeycloakOidcService $kc, KeycloakTokenStore $store)
    {
        $token = $store->getValidAccessToken($kc);

        if (!$token) {
            return response()->json(['status' => false, 'message' => 'session_expired_login_again'], 401);
        }

        $info = $kc->userinfo($token);

        if (!$info['ok']) {
            return response()->json(['status' => false, 'message' => 'userinfo_failed', 'kc' => $info], 401);
        }

        return response()->json([
            'status' => true,
            'userinfo' => $info['data'],
        ]);
    }

    /**
     * ✅ When to use client_credentials():
     * - service-to-service call (cron, backend job)
     * - no user context
     */
    public function serviceToken(KeycloakOidcService $kc)
    {
        $result = $kc->tokenByClientCredentials();

        if (!$result['ok']) {
            return response()->json(['status' => false, 'kc' => $result], 401);
        }

        return response()->json([
            'status' => true,
            'message' => 'Service token (client_credentials)',
            'access_token' => $result['data']['access_token'],
        ]);
    }

    public function logout(KeycloakTokenStore $store)
    {
        // local logout only (clears stored tokens)
        $store->clear();
        return response()->json(['status' => true, 'message' => 'logged_out_locally']);
    }
}
Enter fullscreen mode Exit fullscreen mode

Routes

routes/api.php

use App\Http\Controllers\KeycloakAuthController;

Route::post('/kc/login',   [KeycloakAuthController::class, 'loginWithPassword']);
Route::post('/kc/refresh', [KeycloakAuthController::class, 'refresh']);
Route::get('/kc/me',       [KeycloakAuthController::class, 'me']);
Route::get('/kc/service-token', [KeycloakAuthController::class, 'serviceToken']);
Route::post('/kc/logout',  [KeycloakAuthController::class, 'logout']);
Enter fullscreen mode Exit fullscreen mode

Token-based SSO (Authorization header, NO cookies)



Database table (shared across domains)
user_keycloak_tokens table
CREATE TABLE user_keycloak_tokens (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT UNSIGNED NOT NULL,
    access_token TEXT NOT NULL,
    refresh_token TEXT NOT NULL,
    expires_at INT NOT NULL,
    revoked_at INT NULL,
    device_id VARCHAR(100) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
Enter fullscreen mode Exit fullscreen mode

2️⃣ Model

app/Models/UserKeycloakToken.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class UserKeycloakToken extends Model
{
    protected $table = 'user_keycloak_tokens';

    protected $fillable = [
        'user_id',
        'access_token',
        'refresh_token',
        'expires_at',
        'revoked_at',
        'device_id',
    ];

    protected $casts = [
        'revoked_at' => 'integer',
        'expires_at' => 'integer',
    ];

    public function isRevoked(): bool
    {
        return !is_null($this->revoked_at);
    }

    public function isExpired(): bool
    {
        return time() >= $this->expires_at;
    }
}
Enter fullscreen mode Exit fullscreen mode

3️⃣ Login Controller (Password Grant → DB store)

app/Http/Controllers/AuthController.php

use App\Models\User;
use App\Models\UserKeycloakToken;
use App\Services\KeycloakOidcService;
use Illuminate\Http\Request;

public function login(Request $request, KeycloakOidcService $kc)
{
    $request->validate([
        'username' => 'required|string',
        'password' => 'required|string',
    ]);

    $res = $kc->tokenByPassword($request->username, $request->password);

    if (!$res['ok']) {
        return response()->json(['message' => 'login_failed'], 401);
    }

    $tokens = $res['data'];

    // find or create local user
    $user = User::firstOrCreate(
        ['email' => $request->username],
        ['name' => $request->username]
    );

    // store token in DB
$hash = hash('sha256', $tokens['access_token']);

UserKeycloakToken::updateOrCreate(
  ['user_id' => $user->id, 'device_id' => $deviceId],
  [
    'access_token_hash' => $hash,
    'access_token'      => $tokens['access_token'], // optional to keep; can remove later
    'refresh_token'     => $tokens['refresh_token'],
    'expires_at'        => time() + (int)$tokens['expires_in'],
    'revoked_at'        => null,
  ]
);

    return response()->json([
        'access_token' => $tokens['access_token'],
        'token_type'   => 'Bearer',
        'expires_in'   => $tokens['expires_in'],
    ]);
}
Enter fullscreen mode Exit fullscreen mode

📌 Frontend stores ONLY access_token

4️⃣ JWT Verification Middleware (ALL domains)

This is the heart of Pattern A.

app/Http/Middleware/KeycloakJwtAuth.php

<?php

namespace App\Http\Middleware;

use Closure;
use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use App\Models\UserKeycloakToken;

class KeycloakJwtAuth
{
    public function handle(Request $request, Closure $next)
    {
        $auth = $request->header('Authorization');

        if (!$auth || !str_starts_with($auth, 'Bearer ')) {
            return response()->json(['message' => 'missing_token'], 401);
        }

        $token = substr($auth, 7);

        // 1️⃣ Check DB (revocation)
        $record = UserKeycloakToken::where('access_token', $token)->first();

        if (!$record || $record->isRevoked()) {
            return response()->json(['message' => 'token_revoked'], 401);
        }

        // 2️⃣ Verify JWT signature
        try {
            $jwks = Cache::remember('kc_jwks', 3600, function () {
                return Http::get(
                    config('services.keycloak.base_url') .
                    '/realms/' . config('services.keycloak.realm') .
                    '/protocol/openid-connect/certs'
                )->json();
            });

            $keys = JWK::parseKeySet($jwks);
            $decoded = JWT::decode($token, $keys);

        } catch (\Throwable $e) {
            return response()->json(['message' => 'invalid_token'], 401);
        }

        // 3️⃣ Expired? Try refresh via DB refresh_token
        if ($record->isExpired()) {

            $kc = app(\App\Services\KeycloakOidcService::class);
            $res = $kc->tokenByRefreshToken($record->refresh_token);

            if (!$res['ok']) {
                $record->update(['revoked_at' => time()]);
                return response()->json(['message' => 'session_expired'], 401);
            }

            $data = $res['data'];

            $record->update([
                'access_token'  => $data['access_token'],
                'refresh_token' => $data['refresh_token'] ?? $record->refresh_token,
                'expires_at'    => time() + $data['expires_in'],
            ]);

            // frontend must retry with new token
            return response()->json([
                'message' => 'token_refreshed_retry',
                'access_token' => $data['access_token']
            ], 401);
        }

        // 4️⃣ Attach user info
        $request->attributes->set('kc_user_id', $record->user_id);

        return $next($request);
    }
}
Enter fullscreen mode Exit fullscreen mode

5️⃣ Register Middleware

app/Http/Kernel.php

protected $routeMiddleware = [
    'kc.jwt' => \App\Http\Middleware\KeycloakJwtAuth::class,
];
Enter fullscreen mode Exit fullscreen mode

6️⃣ Protected Routes (ANY DOMAIN)

Route::middleware(['kc.jwt'])->group(function () {
    Route::get('/me', function (Request $request) {
        return response()->json([
            'user_id' => $request->get('kc_user_id'),
            'status'  => 'authenticated'
        ]);
    });
});
Enter fullscreen mode Exit fullscreen mode

7️⃣ Logout (Cross-Domain, DB-based)

public function logout(Request $request)
{
    $auth = $request->header('Authorization');

    if ($auth && str_starts_with($auth, 'Bearer ')) {
        $token = substr($auth, 7);

        UserKeycloakToken::where('access_token', $token)
            ->update(['revoked_at' => time()]);
    }

    return response()->json(['message' => 'logged_out_everywhere']);
}
======================OR===============================
public function logout(Request $request)
{
    $auth = $request->header('Authorization');

    if ($auth && str_starts_with($auth, 'Bearer ')) {
        $token = substr($auth, 7);

        UserKeycloakToken::where('access_token', $token)
           ->update(['revoked_at' => now()]);
    }

    return response()->json(['message' => 'logged_out_everywhere']);
}
Enter fullscreen mode Exit fullscreen mode

in handle methode middleware

// 0️⃣ If token exists, check revoked in DB
if ($accessToken) {
   $tokenHash = hash('sha256', $token);

    // ✅ Revocation check (fast)
    $row = UserKeycloakToken::where('access_token_hash', $tokenHash)->first();

    // If token not found in DB OR revoked => treat as logged out
    if (!$row || $row->revoked_at !== null) {
        Log::warning("⛔ [KC_AUTO] Token revoked or not found in DB", [
            'found' => $row ? 'YES' : 'NO',
            'revoked_at' => $row->revoked_at ?? null,
        ]);

        // force local logout too
        Auth::logout();
        session()->invalidate();
        session()->regenerateToken();

        // For web redirect, for API 401
        return $request->expectsJson()
            ? response()->json(['status' => false, 'message' => 'token_revoked'], 401)
            : redirect('/login');
    }

$now = time();
    $skew = 30; // seconds (refresh slightly before expiry)

    if (!empty($row->expires_at) && $now >= ((int)$row->expires_at - $skew)) {

        Log::info('🔁 [KC_AUTO] Access token expired/expiring, refreshing...', [
            'user_id'    => $row->user_id,
            'expires_at' => $row->expires_at,
            'now'        => $now,
        ]);

        /** @var KeycloakOidcService $kc */
        $kc = app(KeycloakOidcService::class);

        $res = $kc->tokenByRefreshToken($row->refresh_token);

        if (!$res['ok']) {
            // Refresh failed => revoke + force login
            $row->update(['revoked_at' => $now]);

            Log::warning('⛔ [KC_AUTO] Refresh failed; token revoked', [
                'user_id' => $row->user_id,
                'kc'      => $res,
            ]);

            if (Auth::check()) Auth::logout();
            $request->session()->invalidate();
            $request->session()->regenerateToken();

            return $request->expectsJson()
                ? response()->json(['status' => false, 'message' => 'session_expired'], 401)
                : redirect('/login');
        }

        $data = $res['data'];

        // Update DB with new tokens
        $newAccess = $data['access_token'];
        $newHash   = hash('sha256', $newAccess);

        $row->update([
            'access_token_hash' => $newHash,
            'access_token'      => $newAccess, // optional: you can stop storing full token later
            'refresh_token'     => $data['refresh_token'] ?? $row->refresh_token,
            'expires_at'        => $now + (int)($data['expires_in'] ?? 0),
            'revoked_at'        => null,
        ]);

        // IMPORTANT: frontend must replace token and retry request
        return response()->json([
            'status'       => false,
            'message'      => 'token_refreshed_retry',
            'access_token' => $newAccess,
            'expires_in'   => (int)($data['expires_in'] ?? 0),
        ], 401);
    }

}
Enter fullscreen mode Exit fullscreen mode

Token Introspection (check “active” server-side) using api endpoints curl

POST $KC_BASE/realms/$REALM/protocol/openid-connect/token/introspect
Enter fullscreen mode Exit fullscreen mode
curl -s -X POST "$KC_BASE/realms/$REALM/protocol/openid-connect/token/introspect" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "client_id=$CLIENT_ID" \
  -d "client_secret=$CLIENT_SECRET" \
  -d "token=$ACCESS_TOKEN"
Enter fullscreen mode Exit fullscreen mode

JWT mapping

Introspection returns JSON like active: true/false, plus claims.

Use this when you suspect revocation, session logout, or token not valid for realm/client.

Userinfo (standard OIDC profile endpoint)

GET $KC_BASE/realms/$REALM/protocol/openid-connect/userinfo

curl -s "$KC_BASE/realms/$REALM/protocol/openid-connect/userinfo" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
Enter fullscreen mode Exit fullscreen mode

JWT mapping

Requires an access_token with proper scopes (typically openid and often profile / email).

Useful to verify what Keycloak will expose to a client.

Logout (end session)

GET/POST $KC_BASE/realms/$REALM/protocol/openid-connect/logout
Enter fullscreen mode Exit fullscreen mode

RP-initiated logout (recommended for browser SSO)

curl -i -X POST "$KC_BASE/realms/$REALM/protocol/openid-connect/logout" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "client_id=$CLIENT_ID" \
  -d "client_secret=$CLIENT_SECRET" \
  -d "refresh_token=$REFRESH_TOKEN"

Enter fullscreen mode Exit fullscreen mode

Browser redirect style (often used in apps)

# id_token_hint is typically ID_TOKEN (JWT)
# post_logout_redirect_uri must be whitelisted in client settings
"$KC_BASE/realms/$REALM/protocol/openid-connect/logout?id_token_hint=$ID_TOKEN&post_logout_redirect_uri=http://yourapp/sso/logged-out"
Enter fullscreen mode Exit fullscreen mode

JWT mapping

id_token_hint must be the ID Token (JWT).

For server-side logout with refresh token: refresh_token is used.

JWKS / Certs (public keys for JWT verification)

GET $KC_BASE/realms/$REALM/protocol/openid-connect/certs

curl -s "$KC_BASE/realms/$REALM/protocol/openid-connect/certs"
Enter fullscreen mode Exit fullscreen mode

JWT mapping

Use JWKS to verify JWT signature (kid header selects the key).

OpenID Provider configuration (discovery)

GET $KC_BASE/realms/$REALM/.well-known/openid-configuration

curl -s "$KC_BASE/realms/$REALM/.well-known/openid-configuration"
Enter fullscreen mode Exit fullscreen mode

JWT mapping

Confirms the correct issuer (issuer == iss claim).

Authorization endpoint (browser login)

GET $KC_BASE/realms/$REALM/protocol/openid-connect/auth
Enter fullscreen mode Exit fullscreen mode

Example (Authorization Code flow):

"$KC_BASE/realms/$REALM/protocol/openid-connect/auth?client_id=$CLIENT_ID&response_type=code&scope=openid%20profile&redirect_uri=http://localhost/callback"
Enter fullscreen mode Exit fullscreen mode

JWT mapping

This returns code, then you exchange it at /token to get JWTs.

Token Exchange (if enabled)

URL

POST $KC_BASE/realms/$REALM/protocol/openid-connect/token
Enter fullscreen mode Exit fullscreen mode
curl -s -X POST "$KC_BASE/realms/$REALM/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
  -d "client_id=$CLIENT_ID" \
  -d "client_secret=$CLIENT_SECRET" \
  -d "subject_token=$ACCESS_TOKEN" \
  -d "requested_token_type=urn:ietf:params:oauth:token-type:access_token"
Enter fullscreen mode Exit fullscreen mode

JWT mapping

Returns another access_token JWT, often with different aud / roles, depending on policy.

Get admin token (for Admin API calls)

Usually you use an admin client (or a service account with realm-management roles).

ADMIN_CLIENT="admin-cli"  # or your confidential admin client
ADMIN_USER="admin"
ADMIN_PASS="adminpass"
Enter fullscreen mode Exit fullscreen mode
ADMIN_TOKEN=$(curl -s -X POST "$KC_BASE/realms/$REALM/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=password" \
  -d "client_id=$ADMIN_CLIENT" \
  -d "username=$ADMIN_USER" \
  -d "password=$ADMIN_PASS" | jq -r .access_token)
Enter fullscreen mode Exit fullscreen mode

JWT mapping

Admin token JWT must have realm-management roles like:

view-users, query-users, manage-users, view-clients, manage-clients, etc.

Users(list,search and create user and set password)

curl -s "$KC_BASE/admin/realms/$REALM/users?max=20" \
  -H "Authorization: Bearer $ADMIN_TOKEN"
Enter fullscreen mode Exit fullscreen mode

Search users


curl -s "$KC_BASE/admin/realms/$REALM/users?search=ashwani" \
  -H "Authorization: Bearer $ADMIN_TOKEN"
Enter fullscreen mode Exit fullscreen mode

Get user by id


USER_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
curl -s "$KC_BASE/admin/realms/$REALM/users/$USER_ID" \
  -H "Authorization: Bearer $ADMIN_TOKEN"
Enter fullscreen mode Exit fullscreen mode

Create user

curl -s -X POST "$KC_BASE/admin/realms/$REALM/users" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled": true,
    "username": "newuser",
    "email": "newuser@example.com",
    "firstName": "New",
    "lastName": "User"
  }' -i
Enter fullscreen mode Exit fullscreen mode

Set password


curl -s -X PUT "$KC_BASE/admin/realms/$REALM/users/$USER_ID/reset-password" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"type":"password","temporary":false,"value":"StrongPass@123"}'
Enter fullscreen mode Exit fullscreen mode

List and get clients

curl -s "$KC_BASE/admin/realms/$REALM/clients?max=50" \
  -H "Authorization: Bearer $ADMIN_TOKEN"
Enter fullscreen mode Exit fullscreen mode

Get client by clientId (search then pick id)

curl -s "$KC_BASE/admin/realms/$REALM/clients?clientId=$CLIENT_ID" \
  -H "Authorization: Bearer $ADMIN_TOKEN"
Enter fullscreen mode Exit fullscreen mode

realm and client roles

curl -s "$KC_BASE/admin/realms/$REALM/roles" \
  -H "Authorization: Bearer $ADMIN_TOKEN"
Enter fullscreen mode Exit fullscreen mode

Client roles

CLIENT_UUID="..." # internal UUID from clients list
curl -s "$KC_BASE/admin/realms/$REALM/clients/$CLIENT_UUID/roles" \
  -H "Authorization: Bearer $ADMIN_TOKEN"
Enter fullscreen mode Exit fullscreen mode

Realm groups

curl -s "$KC_BASE/admin/realms/$REALM/groups?max=50" \
  -H "Authorization: Bearer $ADMIN_TOKEN"
Enter fullscreen mode Exit fullscreen mode

Sessions / Logout user sessions (admin)

User sessions

curl -s "$KC_BASE/admin/realms/$REALM/users/$USER_ID/sessions" \
  -H "Authorization: Bearer $ADMIN_TOKEN"
Enter fullscreen mode Exit fullscreen mode

Logout user (invalidate sessions)

curl -s -X POST "$KC_BASE/admin/realms/$REALM/users/$USER_ID/logout" \
  -H "Authorization: Bearer $ADMIN_TOKEN"
Enter fullscreen mode Exit fullscreen mode

“Which JWT is used where?” quick mapping

Laravel code to get all api endpoints

env + config
.env

KEYCLOAK_BASE_URL=http://localhost:8080
KEYCLOAK_REALM=master
KEYCLOAK_CLIENT_ID=myapp
KEYCLOAK_CLIENT_SECRET=xxxx

# Optional: if you want Admin API calls via service account or admin-cli
KEYCLOAK_ADMIN_REALM=master
KEYCLOAK_ADMIN_CLIENT_ID=admin-cli
KEYCLOAK_ADMIN_CLIENT_SECRET=
KEYCLOAK_ADMIN_USERNAME=admin
KEYCLOAK_ADMIN_PASSWORD=adminpass
Enter fullscreen mode Exit fullscreen mode

config/services.php

'keycloak' => [
    'base_url'       => env('KEYCLOAK_BASE_URL'),
    'realm'          => env('KEYCLOAK_REALM'),
    'client_id'      => env('KEYCLOAK_CLIENT_ID'),
    'client_secret'  => env('KEYCLOAK_CLIENT_SECRET'),

    'admin_realm'         => env('KEYCLOAK_ADMIN_REALM', env('KEYCLOAK_REALM')),
    'admin_client_id'     => env('KEYCLOAK_ADMIN_CLIENT_ID', 'admin-cli'),
    'admin_client_secret' => env('KEYCLOAK_ADMIN_CLIENT_SECRET'),
    'admin_username'      => env('KEYCLOAK_ADMIN_USERNAME'),
    'admin_password'      => env('KEYCLOAK_ADMIN_PASSWORD'),
],
Enter fullscreen mode Exit fullscreen mode

KeycloakService (ALL API endpoints)

Create: app/Services/KeycloakService.php


<?php

namespace App\Services;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;

class KeycloakService
{
    private string $base;
    private string $realm;
    private string $clientId;
    private ?string $clientSecret;

    public function __construct()
    {
        $this->base         = rtrim(config('services.keycloak.base_url'), '/');
        $this->realm        = config('services.keycloak.realm');
        $this->clientId     = config('services.keycloak.client_id');
        $this->clientSecret = config('services.keycloak.client_secret');
    }

    // -------------------------
    // URL builders
    // -------------------------
    public function issuer(): string
    {
        return "{$this->base}/realms/{$this->realm}";
    }

    public function tokenUrl(): string
    {
        return "{$this->issuer()}/protocol/openid-connect/token";
    }

    public function introspectUrl(): string
    {
        return "{$this->issuer()}/protocol/openid-connect/token/introspect";
    }

    public function userinfoUrl(): string
    {
        return "{$this->issuer()}/protocol/openid-connect/userinfo";
    }

    public function logoutUrl(): string
    {
        return "{$this->issuer()}/protocol/openid-connect/logout";
    }

    public function jwksUrl(): string
    {
        return "{$this->issuer()}/protocol/openid-connect/certs";
    }

    public function openidConfigUrl(): string
    {
        return "{$this->issuer()}/.well-known/openid-configuration";
    }

    public function authUrl(): string
    {
        return "{$this->issuer()}/protocol/openid-connect/auth";
    }

    public function adminBaseUrl(string $realm = null): string
    {
        $r = $realm ?: $this->realm;
        return "{$this->base}/admin/realms/{$r}";
    }

    // -------------------------
    // 1) TOKEN APIs
    // -------------------------
    public function tokenPassword(string $username, string $password, array $extra = []): array
    {
        $payload = array_merge([
            'grant_type'    => 'password',
            'client_id'     => $this->clientId,
            'username'      => $username,
            'password'      => $password,
            // OIDC scopes if you need id_token/userinfo etc.
            'scope'         => 'openid profile email',
        ], $extra);

        // confidential client? send secret
        if (!empty($this->clientSecret)) {
            $payload['client_secret'] = $this->clientSecret;
        }

        $res = Http::asForm()->post($this->tokenUrl(), $payload);

        return [
            'ok'     => $res->successful(),
            'status' => $res->status(),
            'data'   => $res->json(),
            'raw'    => $res->body(),
        ];
    }

    public function tokenClientCredentials(array $extra = []): array
    {
        $payload = array_merge([
            'grant_type' => 'client_credentials',
            'client_id'  => $this->clientId,
        ], $extra);

        if (!empty($this->clientSecret)) {
            $payload['client_secret'] = $this->clientSecret;
        }

        $res = Http::asForm()->post($this->tokenUrl(), $payload);

        return [
            'ok'     => $res->successful(),
            'status' => $res->status(),
            'data'   => $res->json(),
            'raw'    => $res->body(),
        ];
    }

    public function refreshToken(string $refreshToken, array $extra = []): array
    {
        $payload = array_merge([
            'grant_type'    => 'refresh_token',
            'client_id'     => $this->clientId,
            'refresh_token' => $refreshToken,
        ], $extra);

        if (!empty($this->clientSecret)) {
            $payload['client_secret'] = $this->clientSecret;
        }

        $res = Http::asForm()->post($this->tokenUrl(), $payload);

        return [
            'ok'     => $res->successful(),
            'status' => $res->status(),
            'data'   => $res->json(),
            'raw'    => $res->body(),
        ];
    }

    // Token Exchange (if enabled)
    public function tokenExchange(string $subjectToken, array $opts = []): array
    {
        $payload = array_merge([
            'grant_type'            => 'urn:ietf:params:oauth:grant-type:token-exchange',
            'client_id'             => $this->clientId,
            'subject_token'         => $subjectToken,
            'requested_token_type'  => 'urn:ietf:params:oauth:token-type:access_token',
        ], $opts);

        if (!empty($this->clientSecret)) {
            $payload['client_secret'] = $this->clientSecret;
        }

        $res = Http::asForm()->post($this->tokenUrl(), $payload);

        return [
            'ok'     => $res->successful(),
            'status' => $res->status(),
            'data'   => $res->json(),
            'raw'    => $res->body(),
        ];
    }

    // -------------------------
    // 2) INTROSPECT
    // -------------------------
    public function introspect(string $token, array $clientAuthOverride = []): array
    {
        $payload = [
            'token'     => $token,
            'client_id' => $clientAuthOverride['client_id'] ?? $this->clientId,
        ];

        $secret = $clientAuthOverride['client_secret'] ?? $this->clientSecret;
        if (!empty($secret)) {
            $payload['client_secret'] = $secret;
        }

        $res = Http::asForm()->post($this->introspectUrl(), $payload);

        return [
            'ok'     => $res->successful(),
            'status' => $res->status(),
            'data'   => $res->json(),
            'raw'    => $res->body(),
        ];
    }

    // -------------------------
    // 3) USERINFO
    // -------------------------
    public function userinfo(string $accessToken): array
    {
        $res = Http::withToken($accessToken)->get($this->userinfoUrl());

        return [
            'ok'     => $res->successful(),
            'status' => $res->status(),
            'data'   => $res->json(),
            'raw'    => $res->body(),
        ];
    }

    // -------------------------
    // 4) LOGOUT
    // -------------------------
    // Server-side logout (recommended for apps) using refresh_token
    public function logoutByRefreshToken(string $refreshToken): array
    {
        $payload = [
            'client_id'     => $this->clientId,
            'refresh_token' => $refreshToken,
        ];

        if (!empty($this->clientSecret)) {
            $payload['client_secret'] = $this->clientSecret;
        }

        $res = Http::asForm()->post($this->logoutUrl(), $payload);

        return [
            'ok'     => $res->successful(),
            'status' => $res->status(),
            'data'   => $res->json(),   // often empty
            'raw'    => $res->body(),
        ];
    }

    // Browser redirect logout URL (id_token_hint is ID_TOKEN JWT)
    public function logoutRedirectUrl(string $idToken, string $postLogoutRedirectUri): string
    {
        $qs = http_build_query([
            'id_token_hint'            => $idToken,
            'post_logout_redirect_uri' => $postLogoutRedirectUri,
            'client_id'                => $this->clientId,
        ]);

        return $this->logoutUrl() . '?' . $qs;
    }

    // -------------------------
    // 5) JWKS + DISCOVERY
    // -------------------------
    public function jwks(bool $cached = true): array
    {
        $cacheKey = "kc:jwks:{$this->realm}";
        if ($cached) {
            return Cache::remember($cacheKey, 60, function () {
                $res = Http::get($this->jwksUrl());
                return $res->json() ?: [];
            });
        }

        $res = Http::get($this->jwksUrl());
        return $res->json() ?: [];
    }

    public function openidConfiguration(bool $cached = true): array
    {
        $cacheKey = "kc:oidc-config:{$this->realm}";
        if ($cached) {
            return Cache::remember($cacheKey, 60, function () {
                $res = Http::get($this->openidConfigUrl());
                return $res->json() ?: [];
            });
        }

        $res = Http::get($this->openidConfigUrl());
        return $res->json() ?: [];
    }

    // -------------------------
    // 6) AUTH URL (for browser login)
    // -------------------------
    public function authorizationUrl(string $redirectUri, string $state, array $opts = []): string
    {
        $params = array_merge([
            'client_id'     => $this->clientId,
            'response_type' => 'code',
            'scope'         => 'openid profile email',
            'redirect_uri'  => $redirectUri,
            'state'         => $state,
        ], $opts);

        return $this->authUrl() . '?' . http_build_query($params);
    }

    // -------------------------
    // 7) ADMIN TOKEN + ADMIN APIs
    // -------------------------
    public function adminToken(): string
    {
        // Cached token (avoid fetching every request)
        return Cache::remember('kc:admin_token', 50, function () {
            $base   = rtrim(config('services.keycloak.base_url'), '/');
            $realm  = config('services.keycloak.admin_realm');
            $url    = "{$base}/realms/{$realm}/protocol/openid-connect/token";

            $payload = [
                'grant_type' => 'password',
                'client_id'  => config('services.keycloak.admin_client_id'),
                'username'   => config('services.keycloak.admin_username'),
                'password'   => config('services.keycloak.admin_password'),
            ];

            $secret = config('services.keycloak.admin_client_secret');
            if (!empty($secret)) {
                $payload['client_secret'] = $secret;
            }

            $res = Http::asForm()->post($url, $payload);
            $json = $res->json();

            if (!$res->successful() || empty($json['access_token'])) {
                throw new \RuntimeException("Keycloak adminToken failed: {$res->status()} {$res->body()}");
            }

            return $json['access_token'];
        });
    }

    private function adminRequest()
    {
        return Http::withToken($this->adminToken())
            ->acceptJson()
            ->asJson();
    }

    // Users
    public function adminUsersList(int $max = 20): array
    {
        $res = $this->adminRequest()->get($this->adminBaseUrl() . "/users", ['max' => $max]);
        return ['ok'=>$res->successful(),'status'=>$res->status(),'data'=>$res->json(),'raw'=>$res->body()];
    }

    public function adminUsersSearch(string $q, int $max = 20): array
    {
        $res = $this->adminRequest()->get($this->adminBaseUrl() . "/users", ['search' => $q, 'max' => $max]);
        return ['ok'=>$res->successful(),'status'=>$res->status(),'data'=>$res->json(),'raw'=>$res->body()];
    }

    public function adminUserGet(string $userId): array
    {
        $res = $this->adminRequest()->get($this->adminBaseUrl() . "/users/{$userId}");
        return ['ok'=>$res->successful(),'status'=>$res->status(),'data'=>$res->json(),'raw'=>$res->body()];
    }

    public function adminUserCreate(array $user): array
    {
        $res = $this->adminRequest()->post($this->adminBaseUrl() . "/users", $user);
        return ['ok'=>$res->successful(),'status'=>$res->status(),'data'=>$res->json(),'raw'=>$res->body()];
    }

    public function adminUserResetPassword(string $userId, string $password, bool $temporary = false): array
    {
        $res = $this->adminRequest()->put($this->adminBaseUrl() . "/users/{$userId}/reset-password", [
            'type'      => 'password',
            'temporary' => $temporary,
            'value'     => $password,
        ]);

        return ['ok'=>$res->successful(),'status'=>$res->status(),'data'=>$res->json(),'raw'=>$res->body()];
    }

    public function adminUserSessions(string $userId): array
    {
        $res = $this->adminRequest()->get($this->adminBaseUrl() . "/users/{$userId}/sessions");
        return ['ok'=>$res->successful(),'status'=>$res->status(),'data'=>$res->json(),'raw'=>$res->body()];
    }

    public function adminUserLogout(string $userId): array
    {
        $res = $this->adminRequest()->post($this->adminBaseUrl() . "/users/{$userId}/logout");
        return ['ok'=>$res->successful(),'status'=>$res->status(),'data'=>$res->json(),'raw'=>$res->body()];
    }

    // Clients
    public function adminClientsList(int $max = 50): array
    {
        $res = $this->adminRequest()->get($this->adminBaseUrl() . "/clients", ['max' => $max]);
        return ['ok'=>$res->successful(),'status'=>$res->status(),'data'=>$res->json(),'raw'=>$res->body()];
    }

    public function adminClientsByClientId(string $clientId): array
    {
        $res = $this->adminRequest()->get($this->adminBaseUrl() . "/clients", ['clientId' => $clientId]);
        return ['ok'=>$res->successful(),'status'=>$res->status(),'data'=>$res->json(),'raw'=>$res->body()];
    }

    // Roles
    public function adminRealmRoles(): array
    {
        $res = $this->adminRequest()->get($this->adminBaseUrl() . "/roles");
        return ['ok'=>$res->successful(),'status'=>$res->status(),'data'=>$res->json(),'raw'=>$res->body()];
    }

    public function adminClientRoles(string $clientUuid): array
    {
        $res = $this->adminRequest()->get($this->adminBaseUrl() . "/clients/{$clientUuid}/roles");
        return ['ok'=>$res->successful(),'status'=>$res->status(),'data'=>$res->json(),'raw'=>$res->body()];
    }

    // Groups
    public function adminGroups(int $max = 50): array
    {
        $res = $this->adminRequest()->get($this->adminBaseUrl() . "/groups", ['max' => $max]);
        return ['ok'=>$res->successful(),'status'=>$res->status(),'data'=>$res->json(),'raw'=>$res->body()];
    }
}
Enter fullscreen mode Exit fullscreen mode

Why this Laravel JWT verification code exists

When you NEED this Laravel JWT verification code

ou must use this code when any of the following is true:

Case 1: You have protected APIs

Example:

/api/user/profile

/api/hospital/update

/api/vehicle/add

/api/admin/*
Enter fullscreen mode Exit fullscreen mode

👉 Every protected API must verify JWT

Why?
Because APIs do not have sessions.
They rely only on JWT to know who the user is.

Case 2: You are NOT calling Keycloak on every request

Most production systems do not call Keycloak for every API request (too slow).

👉 Instead:

Keycloak issues JWT once

Laravel verifies JWT locally

This code replaces:

"Call Keycloak → check user → allow request"

with:

"Verify JWT locally → allow request"

Case 3: You auto-login users using cookies (like your code)

Your middleware:

$keycloak_access_token (cookie)

Laravel must verify:

Token is real

Token is not expired

Token was issued by your Keycloak

Token belongs to your client

Otherwise:

A fake cookie = account takeover risk

Case 4: You trust JWT claims (email, role, phone)

You are doing:

$email = $payload['email'];
$role  = $payload['role'];
Enter fullscreen mode Exit fullscreen mode

👉 This is DANGEROUS unless JWT signature is verified.

Why?
Because payload can be modified easily if not verified.

What JWT verification actually does (step-by-step)

Step 1: Decode JWT (NOT security)
header.payload.signature

Decoding means:

Read JSON

No security yet

Anyone can decode a JWT.

Step 2: Verify signature (REAL security) 🔐

This is where JWKS comes in.

Keycloak signs JWT using a private key.
Laravel must verify using the public key.

Keycloak (private key) → signs token
Laravel (public key) → verifies token

👉 If any byte of JWT is changed → verification fails.

This proves:

Token is issued by Keycloak

Token is not tampered

Step 3: Validate claims (business rules)

What is JWKS and why Laravel uses it

JWKS = JSON Web Key Set

Endpoint:

/realms/{realm}/protocol/openid-connect/certs
Enter fullscreen mode Exit fullscreen mode

Contains:

Public RSA keys

Identified by kid

JWT header contains:

{ "kid": "abc123" }
Enter fullscreen mode Exit fullscreen mode

Laravel:

Reads kid

Finds matching public key from JWKS

Verifies signature

👉 No secret stored in Laravel
👉 Key rotation supported automatically

5️⃣

When JWT verification is ENOUGH (no introspection needed)

JWT verification alone is enough when:

Normal API requests

Read operations

Performance matters

Token expiry is short (5–15 min)

Example:

Route::get('/user/profile')->middleware('kc.jwt');
Enter fullscreen mode Exit fullscreen mode

This gives:

🔥 Fast

🚀 No Keycloak call

🔒 Secure
When JWT verification is NOT enough

JWT verification does NOT tell:

User logged out

Session revoked

User disabled

Admin forced logout

JWT stays valid until exp.

7️⃣ When to ADD introspection on top of JWT


Where this code sits in your Laravel app

JWT verification (decode + verify signature via JWKS)

Install JWT lib (recommended)

In Laravel, the easiest is firebase/php-jwt:

composer require firebase/php-jwt
Enter fullscreen mode Exit fullscreen mode

3.2 JwtVerifier helper

Create: app/Support/JwtVerifier.php

<?php

namespace App\Support;

use App\Services\KeycloakService;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Support\Facades\Log;

class JwtVerifier
{
    public function __construct(private KeycloakService $kc) {}

    public function decodeAndVerify(string $jwt, array $checks = []): ?array
    {
        try {
            $parts = explode('.', $jwt);
            if (count($parts) !== 3) {
                Log::warning("[JWT] Malformed token (not 3 parts)");
                return null;
            }

            $header = json_decode($this->b64urlDecode($parts[0]), true) ?: [];
            $kid = $header['kid'] ?? null;
            $alg = $header['alg'] ?? null;

            if (!$kid || !$alg) {
                Log::warning("[JWT] Missing kid/alg in header", $header);
                return null;
            }

            // Load JWKS and find key by kid
            $jwks = $this->kc->jwks(true);
            $jwk = $this->findJwkByKid($jwks, $kid);
            if (!$jwk) {
                Log::warning("[JWT] No matching JWK for kid", ['kid' => $kid]);
                return null;
            }

            // Convert JWK to PEM public key
            $publicKeyPem = $this->jwkToPem($jwk);
            if (!$publicKeyPem) {
                Log::warning("[JWT] Failed to convert JWK to PEM", ['kid' => $kid]);
                return null;
            }

            // Verify signature (this returns payload as object)
            $payloadObj = JWT::decode($jwt, new Key($publicKeyPem, $alg));
            $payload = json_decode(json_encode($payloadObj), true);

            // Extra claim checks (iss, aud, exp etc.)
            if (!$this->validateClaims($payload, $checks)) {
                return null;
            }

            return $payload;

        } catch (\Throwable $e) {
            Log::warning("[JWT] Verification failed", ['err' => $e->getMessage()]);
            return null;
        }
    }

    private function validateClaims(array $payload, array $checks): bool
    {
        $now = time();

        if (!empty($payload['exp']) && $payload['exp'] < $now) {
            Log::warning("[JWT] Token expired", ['exp' => $payload['exp'], 'now' => $now]);
            return false;
        }

        if (!empty($checks['iss'])) {
            $iss = $payload['iss'] ?? null;
            if ($iss !== $checks['iss']) {
                Log::warning("[JWT] iss mismatch", ['expected'=>$checks['iss'], 'actual'=>$iss]);
                return false;
            }
        }

        if (!empty($checks['aud'])) {
            $audClaim = $payload['aud'] ?? [];
            $aud = is_string($audClaim) ? [$audClaim] : (is_array($audClaim) ? $audClaim : []);
            if (!in_array($checks['aud'], $aud, true)) {
                Log::warning("[JWT] aud mismatch", ['expected'=>$checks['aud'], 'actual'=>$aud]);
                return false;
            }
        }

        return true;
    }

    private function findJwkByKid(array $jwks, string $kid): ?array
    {
        $keys = $jwks['keys'] ?? [];
        foreach ($keys as $k) {
            if (($k['kid'] ?? null) === $kid) return $k;
        }
        return null;
    }

    private function jwkToPem(array $jwk): ?string
    {
        // Works for RSA keys (typical Keycloak RS256)
        if (($jwk['kty'] ?? '') !== 'RSA') return null;

        $n = $jwk['n'] ?? null;
        $e = $jwk['e'] ?? null;
        if (!$n || !$e) return null;

        // Build RSA public key from modulus/exponent
        $modulus = $this->b64urlDecodeToBin($n);
        $exponent = $this->b64urlDecodeToBin($e);

        $rsaPubKey = $this->buildRsaPublicKeyDer($modulus, $exponent);
        if (!$rsaPubKey) return null;

        $pem = "-----BEGIN PUBLIC KEY-----\n" .
            chunk_split(base64_encode($rsaPubKey), 64, "\n") .
            "-----END PUBLIC KEY-----\n";

        return $pem;
    }

    private function buildRsaPublicKeyDer(string $n, string $e): ?string
    {
        // Minimal DER builder for SubjectPublicKeyInfo (RSA)
        // This is production-used pattern; stable for RS256 verification.
        $mod = $this->asn1Integer($n);
        $exp = $this->asn1Integer($e);
        $seq = $this->asn1Sequence($mod . $exp);

        // rsaEncryption OID: 1.2.840.113549.1.1.1 + NULL
        $algo = $this->asn1Sequence(
            $this->asn1Oid("\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01") . // 1.2.840.113549.1.1.1
            $this->asn1Null()
        );

        $bitString = $this->asn1BitString($seq);

        return $this->asn1Sequence($algo . $bitString);
    }

    private function asn1Length(int $len): string
    {
        if ($len < 0x80) return chr($len);
        $out = '';
        while ($len > 0) {
            $out = chr($len & 0xff) . $out;
            $len >>= 8;
        }
        return chr(0x80 | strlen($out)) . $out;
    }

    private function asn1Integer(string $bin): string
    {
        // ensure positive integer
        if (ord($bin[0]) > 0x7f) $bin = "\x00" . $bin;
        return "\x02" . $this->asn1Length(strlen($bin)) . $bin;
    }

    private function asn1Sequence(string $bin): string
    {
        return "\x30" . $this->asn1Length(strlen($bin)) . $bin;
    }

    private function asn1BitString(string $bin): string
    {
        // prepend 0 unused bits
        $bin = "\x00" . $bin;
        return "\x03" . $this->asn1Length(strlen($bin)) . $bin;
    }

    private function asn1Null(): string
    {
        return "\x05\x00";
    }

    private function asn1Oid(string $oidBin): string
    {
        return "\x06" . $this->asn1Length(strlen($oidBin)) . $oidBin;
    }

    private function b64urlDecode(string $in): string
    {
        $remainder = strlen($in) % 4;
        if ($remainder) $in .= str_repeat('=', 4 - $remainder);
        return base64_decode(strtr($in, '-_', '+/')) ?: '';
    }

    private function b64urlDecodeToBin(string $in): string
    {
        return $this->b64urlDecode($in);
    }
}
Enter fullscreen mode Exit fullscreen mode

3.3 Middleware: validate JWT (Bearer or Cookie) and attach payload

Create: app/Http/Middleware/KeycloakJwtAuth.php

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use App\Support\JwtVerifier;

class KeycloakJwtAuth
{
    public function __construct(private JwtVerifier $verifier) {}

    public function handle(Request $request, Closure $next)
    {
        // 1) Token from Authorization header or cookie
        $token = $this->getBearerToken($request) ?: $request->cookie('keycloak_access_token');

        if (!$token) {
            return response()->json(['ok' => false, 'message' => 'Missing access token'], 401);
        }

        // 2) Verify signature + claims
        $expectedIss = rtrim(config('services.keycloak.base_url'), '/') . '/realms/' . config('services.keycloak.realm');
        $expectedAud = config('services.keycloak.client_id');

        $payload = $this->verifier->decodeAndVerify($token, [
            'iss' => $expectedIss,
            'aud' => $expectedAud,   // if your token aud is different, adjust here
        ]);

        if (!$payload) {
            return response()->json(['ok' => false, 'message' => 'Invalid token'], 401);
        }

        // 3) Add payload for controllers
        $request->attributes->set('kc_jwt', $payload);

        Log::info("[KC_JWT] ok", [
            'sub' => $payload['sub'] ?? null,
            'email' => $payload['email'] ?? null,
            'aud' => $payload['aud'] ?? null,
        ]);

        return $next($request);
    }

    private function getBearerToken(Request $request): ?string
    {
        $h = $request->header('Authorization');
        if (!$h) return null;
        if (preg_match('/Bearer\s+(.+)/i', $h, $m)) return trim($m[1]);
        return null;
    }
}
Enter fullscreen mode Exit fullscreen mode

Register middleware in app/Http/Kernel.php:


protected $routeMiddleware = [
    // ...
    'kc.jwt' => \App\Http\Middleware\KeycloakJwtAuth::class,
];
Enter fullscreen mode Exit fullscreen mode

4) Example Laravel Routes + Controller usage for each endpoint
Routes: routes/api.php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\KeycloakApiController;

Route::post('/kc/token/password', [KeycloakApiController::class, 'tokenPassword']);
Route::post('/kc/token/client', [KeycloakApiController::class, 'tokenClient']);
Route::post('/kc/token/refresh', [KeycloakApiController::class, 'tokenRefresh']);
Route::post('/kc/token/exchange', [KeycloakApiController::class, 'tokenExchange']);

Route::post('/kc/introspect', [KeycloakApiController::class, 'introspect']);

Route::get('/kc/userinfo', [KeycloakApiController::class, 'userinfo'])->middleware('kc.jwt');

Route::post('/kc/logout/refresh', [KeycloakApiController::class, 'logoutByRefresh']);
Route::get('/kc/logout/url', [KeycloakApiController::class, 'logoutRedirectUrl']);

Route::get('/kc/jwks', [KeycloakApiController::class, 'jwks']);
Route::get('/kc/openid-config', [KeycloakApiController::class, 'openidConfig']);

// Admin APIs (protect with your own auth or IP restriction)
Route::get('/kc/admin/users', [KeycloakApiController::class, 'adminUsers']);
Route::get('/kc/admin/users/search', [KeycloakApiController::class, 'adminUsersSearch']);
Route::get('/kc/admin/users/{id}', [KeycloakApiController::class, 'adminUserGet']);
Route::post('/kc/admin/users', [KeycloakApiController::class, 'adminUserCreate']);
Route::post('/kc/admin/users/{id}/logout', [KeycloakApiController::class, 'adminUserLogout']);
Route::get('/kc/admin/clients', [KeycloakApiController::class, 'adminClients']);
Route::get('/kc/admin/roles', [KeycloakApiController::class, 'adminRealmRoles']);
Route::get('/kc/admin/groups', [KeycloakApiController::class, 'adminGroups']);
Enter fullscreen mode Exit fullscreen mode

Controller: app/Http/Controllers/KeycloakApiController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Services\KeycloakService;

class KeycloakApiController extends Controller
{
    public function __construct(private KeycloakService $kc) {}

    // ---- TOKEN ----
    public function tokenPassword(Request $r)
    {
        return response()->json(
            $this->kc->tokenPassword($r->username, $r->password)
        );
    }

    public function tokenClient()
    {
        return response()->json(
            $this->kc->tokenClientCredentials()
        );
    }

    public function tokenRefresh(Request $r)
    {
        return response()->json(
            $this->kc->refreshToken($r->refresh_token)
        );
    }

    public function tokenExchange(Request $r)
    {
        return response()->json(
            $this->kc->tokenExchange($r->subject_token, $r->only(['audience','requested_subject','scope']))
        );
    }

    // ---- INTROSPECT ----
    public function introspect(Request $r)
    {
        return response()->json(
            $this->kc->introspect($r->token)
        );
    }

    // ---- USERINFO (JWT middleware already verified signature) ----
    public function userinfo(Request $r)
    {
        // Option A: Use userinfo endpoint (needs access_token)
        $token = $r->bearerToken() ?: $r->cookie('keycloak_access_token');
        return response()->json($this->kc->userinfo($token));

        // Option B: Directly return decoded verified claims
        // return response()->json(['ok'=>true, 'claims'=>$r->attributes->get('kc_jwt')]);
    }

    // ---- LOGOUT ----
    public function logoutByRefresh(Request $r)
    {
        return response()->json(
            $this->kc->logoutByRefreshToken($r->refresh_token)
        );
    }

    public function logoutRedirectUrl(Request $r)
    {
        $idToken = $r->query('id_token');
        $redirect = $r->query('post_logout_redirect_uri', url('/'));

        return response()->json([
            'ok' => true,
            'url' => $this->kc->logoutRedirectUrl($idToken, $redirect),
        ]);
    }

    // ---- JWKS + DISCOVERY ----
    public function jwks()
    {
        return response()->json(['ok'=>true, 'data'=>$this->kc->jwks(true)]);
    }

    public function openidConfig()
    {
        return response()->json(['ok'=>true, 'data'=>$this->kc->openidConfiguration(true)]);
    }

    // ---- ADMIN ----
    public function adminUsers(Request $r)
    {
        return response()->json($this->kc->adminUsersList((int)($r->max ?? 20)));
    }

    public function adminUsersSearch(Request $r)
    {
        return response()->json($this->kc->adminUsersSearch($r->q ?? '', (int)($r->max ?? 20)));
    }

    public function adminUserGet(string $id)
    {
        return response()->json($this->kc->adminUserGet($id));
    }

    public function adminUserCreate(Request $r)
    {
        return response()->json($this->kc->adminUserCreate($r->all()));
    }

    public function adminUserLogout(string $id)
    {
        return response()->json($this->kc->adminUserLogout($id));
    }

    public function adminClients(Request $r)
    {
        return response()->json($this->kc->adminClientsList((int)($r->max ?? 50)));
    }

    public function adminRealmRoles()
    {
        return response()->json($this->kc->adminRealmRoles());
    }

    public function adminGroups(Request $r)
    {
        return response()->json($this->kc->adminGroups((int)($r->max ?? 50)));
    }
}
Enter fullscreen mode Exit fullscreen mode

Laravel code to get all api endpoints

Top comments (0)