Debug School

rakesh kumar
rakesh kumar

Posted on • Edited on

Keylock Debugging

invalid_client (Client authentication failed)
unauthorized_client (Client not allowed for this grant / flow)
invalid_grant (Bad username/password, expired code, wrong redirect_uri)
invalid_token (Token expired / wrong audience / wrong signature)
CORS errors (Browser blocks the request)
403 Forbidden / “Insufficient permissions” (Admin API / Token exchange)
invalid_scope (Scopes not allowed)
Practical Debugging Pattern (use this in your blog)
Userinfo (standard OIDC profile endpoint) using jwt

Check Keycloak logs for the real error

curl -X POST "https://auth.motoshare.in/realms/motoshare/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=motoshare" \
  -d "client_secret=YOUR_SECRET" \
  -d "requested_token_type=urn:ietf:params:oauth:token-type:access_token"
Enter fullscreen mode Exit fullscreen mode

Correct Token Exchange CURL (Full Example)
STEP 1: First obtain a user access token

curl -X POST "https://auth.motoshare.in/realms/motoshare/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=password" \
  -d "client_id=motoshare" \
  -d "client_secret=L0JQfUYqSueoxhOSqCi77ihu3LppLsOB" \
  -d "username=rakeshdev.cotocus@gmail.com" \
  -d "password=rkgmkgskg@1234"
Enter fullscreen mode Exit fullscreen mode

STEP 2: Perform token exchange using that access token

curl -X POST "https://auth.motoshare.in/realms/motoshare/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=motoshare" \
  -d "client_secret=L0JQfUYqSueoxhOSqCi77ihu3LppLsOB" \
  -d "subject_token=THE_USER_ACCESS_TOKEN" \
  -d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
  -d "requested_token_type=urn:ietf:params:oauth:token-type:access_token"
Enter fullscreen mode Exit fullscreen mode

how-to-run-keycloak-in-production-with-apache-and-systemd-step-by-step-guide

STEP 1 — Check Keycloak logs for the real error

Run:

journalctl -u keycloak -f
Enter fullscreen mode Exit fullscreen mode

or

tail -n 200 /opt/auth.motoshare.in/standalone/log/server.log
Enter fullscreen mode Exit fullscreen mode

sudo systemctl daemon-reload
sudo systemctl enable keycloak
sudo systemctl start keycloak
sudo systemctl status keycloak
sudo systemctl restart keycloak
Enter fullscreen mode Exit fullscreen mode

How to enable token exchange

nano /opt/auth.motoshare.in/conf/keycloak.conf
features=token-exchange,token-exchange-standard,persistent-user-sessions
cd /opt/auth.motoshare.in/bin
./kc.sh build

sudo systemctl restart keycloak
Enter fullscreen mode Exit fullscreen mode

How to get client id and client secreat by command

opt/auth.motoshare.in/bin# ./kcadm.sh get clients -r motoshare --fields id,clientI

invalid_client (Client authentication failed)

Why it happens (theory)

Keycloak can’t authenticate your client application. Usually:

wrong client_id

wrong client_secret

client is set to Public but you’re sending a secret (or vice-versa)

you’re using the wrong realm URL

Example (Node.js: token request using client_secret)

import fetch from "node-fetch";

const KC_BASE = "http://localhost:8080";
const REALM = "master";
const CLIENT_ID = "myapp";
const CLIENT_SECRET = "WRONG_SECRET"; // ❌

async function getToken() {
  const url = `${KC_BASE}/realms/${REALM}/protocol/openid-connect/token`;

  const body = new URLSearchParams({
    grant_type: "client_credentials",
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET
  });

  const res = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body
  });

  const data = await res.json();
  console.log(res.status, data);
}

getToken();
Enter fullscreen mode Exit fullscreen mode

in laravel

<?php

namespace App\Http\Controllers;

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

class KeycloakTokenController extends Controller
{
    public function clientCredentialsToken()
    {
        $kcBase   = rtrim(env('KEYCLOAK_BASE_URL', 'http://localhost:8080'), '/');
        $realm    = env('KEYCLOAK_REALM', 'master');
        $clientId = env('KEYCLOAK_CLIENT_ID', 'myapp');
        $secret   = env('KEYCLOAK_CLIENT_SECRET', 'WRONG_SECRET'); // ❌ for testing

        $tokenUrl = "{$kcBase}/realms/{$realm}/protocol/openid-connect/token";

        $response = Http::asForm()
            ->timeout(20)
            ->post($tokenUrl, [
                'grant_type'    => 'client_credentials',
                'client_id'     => $clientId,
                'client_secret' => $secret,
            ]);

        // Always log safely (never log secrets)
        Log::info('[KC] client_credentials token response', [
            'url' => $tokenUrl,
            'status' => $response->status(),
            'body' => $response->json(),
            'client_id' => $clientId,
            'realm' => $realm,
        ]);

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

2) Route

use App\Http\Controllers\KeycloakTokenController;

Route::get('/debug/kc-token', [KeycloakTokenController::class, 'clientCredentialsToken']);
Enter fullscreen mode Exit fullscreen mode

3) .env (example)

KEYCLOAK_BASE_URL=http://localhost:8080
KEYCLOAK_REALM=master
KEYCLOAK_CLIENT_ID=myapp
KEYCLOAK_CLIENT_SECRET=xxxxx
Enter fullscreen mode Exit fullscreen mode

Example (Password grant fails because Direct Access Grants off)

curl -X POST "http://localhost:8080/realms/master/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=password" \
  -d "client_id=myapp" \
  -d "client_secret=xxxxx" \
  -d "username=test" \
  -d "password=Secret123"
Enter fullscreen mode Exit fullscreen mode
curl -X POST "https://auth.motoshare.in/realms/motoshare/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=password" \
  -d "client_id=motoshare" \
  -d "client_secret=qOPC04bglJ9bsIWzGfqfpVIsjogYbDj4" \
  -d "username=rakeshdev.cotocus@gmail.com" \
  -d "password=rkgmkgskg@1234"
Enter fullscreen mode Exit fullscreen mode

unauthorized_client (Client not allowed for this grant / flow)

Why it happens (theory)

Client is valid, but the grant type / flow is not enabled, e.g.:

trying client_credentials but service account disabled

trying password grant but “Direct Access Grants” disabled

trying Token Exchange but feature/permissions not configured

Example (Password grant fails because Direct Access Grants off)
Enter fullscreen mode Exit fullscreen mode
curl -X POST "http://localhost:8080/realms/master/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=password" \
  -d "client_id=myapp" \
  -d "client_secret=xxxxx" \
  -d "username=test" \
  -d "password=Secret123"
Enter fullscreen mode Exit fullscreen mode

invalid_grant (Bad username/password, expired code, wrong redirect_uri)

Why it happens (theory)

Keycloak accepted the client, but the user-side grant is invalid:

wrong username/password

user disabled / required actions pending

using auth code but code already used/expired

redirect_uri mismatch for authorization code flow

Example (Laravel: exchange authorization code)

public function callback(Request $request)
{
    $code = $request->query('code');
    if (!$code) abort(400, 'Missing code');

    $tokenUrl = config('services.keycloak.base_url') . "/realms/" . config('services.keycloak.realm')
        . "/protocol/openid-connect/token";

    $redirectUri = route('kc.callback'); // MUST match exactly what Keycloak expects

    $res = Http::asForm()->post($tokenUrl, [
        'grant_type' => 'authorization_code',
        'client_id' => config('services.keycloak.client_id'),
        'client_secret' => config('services.keycloak.client_secret'),
        'code' => $code,
        'redirect_uri' => $redirectUri,
    ]);

    if (!$res->ok()) {
        \Log::error("[KC] token exchange failed", [
            'status' => $res->status(),
            'body' => $res->json(),
            'redirect_uri' => $redirectUri,
        ]);
        abort(401, 'Keycloak token exchange failed');
    }

    return $res->json();
}
Enter fullscreen mode Exit fullscreen mode

Fix checklist

Verify redirect_uri exact match (scheme, host, path, trailing slash)

Check if user must complete required actions (update password / verify email)

Ensure code is used only once and quickly (auth codes are short-lived)

invalid_token (Token expired / wrong audience / wrong signature)

Why it happens (theory)

Your API received a token but can’t accept it:

token expired

token signed by different realm/issuer

token audience doesn’t include your API client

you validated with wrong JWKS URL

Example (Laravel middleware: basic token validation via introspection)

public function handle($request, Closure $next)
{
    $token = $request->bearerToken();
    if (!$token) return response()->json(['message' => 'Missing Bearer token'], 401);

    $introspectUrl = config('services.keycloak.base_url') . "/realms/" . config('services.keycloak.realm')
        . "/protocol/openid-connect/token/introspect";

    $res = Http::asForm()->post($introspectUrl, [
        'client_id' => config('services.keycloak.client_id'),
        'client_secret' => config('services.keycloak.client_secret'),
        'token' => $token,
    ]);

    $data = $res->json();
    if (!$res->ok() || empty($data['active'])) {
        \Log::warning("[KC] invalid token", ['status' => $res->status(), 'body' => $data]);
        return response()->json(['message' => 'Invalid or expired token'], 401);
    }

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

Fix checklist

Confirm issuer/realm matches

Ensure token has correct aud (audience)

Use correct JWKS or introspection client creds

Handle refresh properly if token expired

CORS errors (Browser blocks the request)

Why it happens (theory)

Keycloak is fine, but the browser blocks due to missing CORS headers for your origin.

Example symptom

Console shows: CORS policy: No 'Access-Control-Allow-Origin'...

Fix checklist

Keycloak client → Web Origins: add your frontend origin

Example: http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

Also set Valid Redirect URIs properly

Prefer server-side token exchange when possible

403 Forbidden / “Insufficient permissions” (Admin API / Token exchange)

What it means

You have a token, but it does not have required roles to call Admin endpoints.

Common causes

Using a user token without admin roles

Using service account token but missing realm-management roles

Using wrong realm token for a different realm’s admin endpoint

Why it happens (theory)

You authenticated, but Keycloak denies the action because:

missing realm roles / client roles

service account lacks permissions

token exchange not allowed to that target/audience

Example (Laravel: calling Admin API without proper role)

$adminUrl = $kcBase . "/admin/realms/$realm/users";

$res = Http::withToken($adminAccessToken)->get($adminUrl);

if ($res->status() === 403) {
    \Log::error("[KC] admin api forbidden", ['body' => $res->json()]);
    // Usually means service account missing roles like view-users / query-users / manage-users
}
Enter fullscreen mode Exit fullscreen mode
$res = Http::withToken($adminToken)
    ->timeout(20)
    ->get("{$kcBase}/admin/realms/{$realm}/users", ['max' => 10]);

if ($res->status() === 403) {
    \Log::error('[KC] Admin API forbidden', [
        'status' => $res->status(),
        'body' => $res->json(),
        'realm' => $realm,
        'endpoint' => 'users list',
    ]);
}
Enter fullscreen mode Exit fullscreen mode

Fix checklist

For Admin API:

Assign service account roles (realm-management client roles)

For Token Exchange:

configure permissions/policies for exchanging + target audience

invalid_scope (Scopes not allowed)

Why it happens (theory)

You requested scopes that:

aren’t configured for that client

aren’t permitted by client scopes / consent rules

What it means (simple theory)

Your request contains a scope value Keycloak will not grant for that client/user/flow.

Most common causes (real)

Asking for a scope that the client doesn’t have permission to use

Asking offline_access when it’s not enabled/allowed

Asking custom scope not mapped to any client scope

Token Exchange: asking scopes not permitted for exchanged token

Some clients require consent; scope requires consent but not granted

Example (Laravel) requesting scopes

$response = Http::asForm()->post($tokenUrl, [
    'grant_type' => 'client_credentials',
    'client_id' => $clientId,
    'client_secret' => $secret,
    'scope' => 'openid profile email offline_access', // may trigger invalid_scope
]);
Enter fullscreen mode Exit fullscreen mode

Example

-d "scope=openid profile email offline_access some_custom_scope"
Enter fullscreen mode Exit fullscreen mode

Fix checklist

Client Scopes: add/assign scope properly

If you need refresh tokens: ensure offline_access is allowed + flow supports it

Practical Debugging Pattern (use this in your blog)

Step A: Always log the exact Keycloak response

HTTP status

response JSON

token endpoint URL

client_id + realm (never log secrets)

Enter fullscreen mode Exit fullscreen mode

Step B: Confirm these 5 items first

Realm URL

Client type (Public/Confidential)

Enabled flow (auth code, password, service account, token exchange)

Redirect URI / Web Origins

Roles/permissions (for admin API or token exchange)
Enter fullscreen mode Exit fullscreen mode

Userinfo (standard OIDC profile endpoint)

Step 1:add user and credential

select realm from manage realm(left-side-bar)
Enter fullscreen mode Exit fullscreen mode

set paasword or credential

remove required user action if present
other wise error
invalid grant
account not fully setup

follow this part of blog
Steps to Enable Custom User Attributes
how-to-enable-use-custom-user-attributes-in-keycloak

After adding custom user attribute get access token using curl

apply curl to get access token

curl -X POST "https://auth.motoshare.in/realms/motoshare/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=password" \
  -d "client_id=motoshare" \
  -d "client_secret=qOPC04bglJ9bsIWzGfqfpVIsjogYbDj4" \
  -d "username=rakeshdev.cotocus@gmail.com" \
  -d "password=rkgmkgskg@1234"
Enter fullscreen mode Exit fullscreen mode

output

in output get access token use this accesstoken to get Userinfo (standard OIDC profile endpoint) using jwt


using jwt

 if ($accessToken) {
        Log::info("🔵[KC_AUTO] Validating access token via JWT decode…");

        $payload = $this->decodeJwtPayload($accessToken);

        if (!$payload) {
            Log::warning("⚠️ [KC_AUTO] Failed to decode JWT payload");
            Log::warning("⚠️ [KC_AUTO] Auto-login skipped (invalid token format)");
            return $next($request);
        }

        Log::info("🔵 [KC_AUTO] Decoded JWT payload", $payload);

        // ---- basic checks: exp / iss / aud --------------------------------
        $now = time();

        $exp = $payload['exp'] ?? null;
        if (!$exp || $exp < $now) {
            Log::warning("⚠️ [KC_AUTO] JWT token expired", [
                'exp' => $exp,
                'now' => $now,
            ]);
            return $next($request);
        }

        $realm  = env('KEYCLOAK_REALM');
        $base   = rtrim(env('KEYCLOAK_BASE_URL'), '/');        // e.g. https://auth.motoshare.in
        $issuerExpected = "{$base}/realms/{$realm}";
        $issuerActual   = $payload['iss'] ?? null;

        if ($issuerActual !== $issuerExpected) {
            Log::warning("⚠️ [KC_AUTO] JWT iss mismatch", [
                'expected' => $issuerExpected,
                'actual'   => $issuerActual,
            ]);
            return $next($request);
        }

        $expectedAud = env('KEYCLOAK_CLIENT_ID');              // motoshare
        $audClaim    = $payload['aud'] ?? [];

        if (is_string($audClaim)) {
            $audClaim = [$audClaim];
        }

        if ($expectedAud && !in_array($expectedAud, $audClaim, true)) {
            Log::warning("⚠️ [KC_AUTO] JWT aud mismatch", [
                'expected' => $expectedAud,
                'actual'   => $audClaim,
            ]);
            return $next($request);
        }

        // -----------------------------------------------------------------
        // 3️⃣ Extract useful claims and auto-login Laravel user
        // -----------------------------------------------------------------
      $email    = $payload['email'] ?? null;
$phone    = $payload['phone'] ?? ($payload['phone_number'] ?? null);
$username = $payload['preferred_username'] ?? null;
$kcUserId = $payload['sub'] ?? null;

$role     = $payload['role'] ?? null;
$status   = $payload['status'] ?? null;
$address  = $payload['address'] ?? null;

// ✅ handle possible key typo with space: " sms_notification"
$smsNotification      = $payload['sms_notification'] ?? ($payload[' sms_notification'] ?? null);
$emailNotification    = $payload['email_notification'] ?? null;
$whatsappNotification = $payload['whatsapp_notification'] ?? null;


        Log::info("📞 [KC_AUTO] Extracted user info from JWT", [
            'email'    => $email,
            'phone'    => $phone,
            'username' => $username,
            'kcUserId' => $kcUserId,
        ]);

        if ($email) {
            // 👇 Same flow as before, just driven by JWT instead of introspect
            return $this->autoLoginLaravelUser(
                $email,
                $phone,
                $kcUserId,
                $username,
                $role,
        $status,
        $address,
        $smsNotification,
        $emailNotification,
        $whatsappNotification,

                $request,
                $next
            );
        }

        Log::warning("⚠️ [KC_AUTO] JWT does not contain email, cannot auto-login");
    }
Enter fullscreen mode Exit fullscreen mode

=========Another way Userinfo using jwt=====================

Step 1: Add custom user attributes in Keycloak (where you already did)

Example custom attributes on a Keycloak user:

phone

role

status

address

sms_notification

email_notification

whatsapp_notification
Enter fullscreen mode Exit fullscreen mode

Important point (why sometimes you don’t see attributes)

Keycloak does NOT automatically put user attributes into access_token/userinfo unless you add a Protocol Mapper.

Step 2: Add Protocol Mappers so your custom attributes come in token/userinfo

Go to:

Keycloak Admin → Clients → motoshare → Client scopes / Mappers
Enter fullscreen mode Exit fullscreen mode

Create mappers (repeat for each attribute):

Mapper settings (example: phone)

Mapper Type: User Attribute

User Attribute: phone (must match exactly your attribute key)

Token Claim Name: phone (or phone_number, your choice)

Claim JSON Type: String

Enable:

✅ Add to access token

✅ Add to ID token

✅ Add to userinfo

Do same for:

role, status, address

sms_notification, email_notification, whatsapp_notification

✅ Now your token + userinfo will include these.

Step 3: Get access token using cURL (your command)

Your cURL is correct for Direct Access Grant (password grant):

curl -X POST "https://auth.motoshare.in/realms/motoshare/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=password" \
  -d "client_id=motoshare" \
  -d "client_secret=qOPC04bglJ9bsIWzGfqfpVIsjogYbDj4" \
  -d "username=rakeshdev.cotocus@gmail.com" \
  -d "password=rkgmkgskg@1234"
Enter fullscreen mode Exit fullscreen mode

Keycloak response JSON contains:

access_token ✅ (use this next)

expires_in

refresh_token

token_type
Enter fullscreen mode Exit fullscreen mode

Step 4: Call Userinfo endpoint using the access token

Userinfo endpoint:

GET https://auth.motoshare.in/realms/motoshare/protocol/openid-connect/userinfo
Enter fullscreen mode Exit fullscreen mode

cURL example:

curl -X GET "https://auth.motoshare.in/realms/motoshare/protocol/openid-connect/userinfo" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
Enter fullscreen mode Exit fullscreen mode

If your mappers are enabled (Step 2), you’ll see your custom attributes in output.

Step 5: Laravel config (.env)

Add:

KEYCLOAK_BASE_URL=https://auth.motoshare.in
KEYCLOAK_REALM=motoshare
KEYCLOAK_CLIENT_ID=motoshare
KEYCLOAK_CLIENT_SECRET=qOPC04bglJ9bsIWzGfqfpVIsjogYbDj4
Enter fullscreen mode Exit fullscreen mode

Step 6: Laravel code to get access token (same as your cURL)

Create a service: app/Services/KeycloakService.php

<?php

namespace App\Services;

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

class KeycloakService
{
    public function tokenPasswordGrant(string $username, string $password): ?array
    {
        $base  = rtrim(env('KEYCLOAK_BASE_URL'), '/');
        $realm = env('KEYCLOAK_REALM');

        $url = "{$base}/realms/{$realm}/protocol/openid-connect/token";

        $res = Http::asForm()->post($url, [
            'grant_type'    => 'password',
            'client_id'     => env('KEYCLOAK_CLIENT_ID'),
            'client_secret' => env('KEYCLOAK_CLIENT_SECRET'),
            'username'      => $username,
            'password'      => $password,
        ]);

        if (!$res->ok()) {
            Log::error("[KC] Token request failed", [
                'status' => $res->status(),
                'body'   => $res->json(),
            ]);
            return null;
        }

        return $res->json();
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

$kc = app(\App\Services\KeycloakService::class);

$data = $kc->tokenPasswordGrant($username, $password);
$accessToken = $data['access_token'] ?? null;
Enter fullscreen mode Exit fullscreen mode

Step 7: Laravel code to call Userinfo using access token

Add this method in the same service:

public function userInfo(string $accessToken): ?array
{
    $base  = rtrim(env('KEYCLOAK_BASE_URL'), '/');
    $realm = env('KEYCLOAK_REALM');

    $url = "{$base}/realms/{$realm}/protocol/openid-connect/userinfo";

    $res = Http::withToken($accessToken)->get($url);

    if (!$res->ok()) {
        Log::warning("[KC] Userinfo failed", [
            'status' => $res->status(),
            'body'   => $res->json(),
        ]);
        return null;
    }

    return $res->json();
}


Usage:

$userinfo = $kc->userInfo($accessToken);

Log::info("[KC] userinfo", $userinfo ?? []);
Enter fullscreen mode Exit fullscreen mode

Step 8: JWT “decode” vs “verify” (your middleware code explanation)

Your current code:

✅ decodes payload

✅ checks exp, iss, aud

❌ does NOT verify signature (unless your decodeJwtPayload() actually validates signature using public keys)

Why signature verification matters

A token can be “decoded” even if it’s fake. Real validation needs:

Download realm JWKS

Validate token signature with the correct public key

Then trust claims

If you want production-grade JWT verification

Use a JWT library (commonly firebase/php-jwt) and validate using Keycloak JWKS.
If you want, I can paste the exact Laravel-ready verify function (JWKS fetch + cache + validate) that drops into your middleware.

Step 9: Mapping your custom attributes (JWT or Userinfo)

After you have either:

$payload from JWT

or $userinfo from userinfo endpoint

You extract like this (your pattern is fine):

$email    = $payload['email'] ?? null;
$phone    = $payload['phone'] ?? ($payload['phone_number'] ?? null);
$username = $payload['preferred_username'] ?? null;
$kcUserId = $payload['sub'] ?? null;

$role     = $payload['role'] ?? null;
$status   = $payload['status'] ?? null;
$address  = $payload['address'] ?? null;

$smsNotification      = $payload['sms_notification'] ?? ($payload[' sms_notification'] ?? null);
$emailNotification    = $payload['email_notification'] ?? null;
$whatsappNotification = $payload['whatsapp_notification'] ?? null;
Enter fullscreen mode Exit fullscreen mode

Top comments (0)