Debug School

rakesh kumar
rakesh kumar

Posted on • Edited on

How to Enable & Use Custom User Attributes in Keycloak

What Are Custom User Attributes?
Why Keycloak Removed the Old Attributes Section (Keycloak 22–26)
Understanding the Declarative User Profile
Steps to Enable Custom User Attributes
How Backend (Laravel / Node / Go) Sends Custom Attributes
How Custom Attributes Are Used in Real Applications
Common Errors and Why They Happen
Best Practices for Production
Different way to check custom user attribute added in keycloack

Before Keycloak 21, you could directly add attributes on the user page.
Now Keycloak enforces Declarative User Profile, which means:

You must define allowed attributes

You must declare their permissions

Keycloak GUI will show the field only if it exists in the JSON schema

This gives Keycloak:

Better validation

Security control

Cleaner identity model

Rule-based attribute editing

Modern applications need more than just usernames and emails. They need mobile numbers, user types, business IDs, foreign keys, domains, and more.
To support these capabilities, Keycloak provides a powerful mechanism called Custom User Attributes.

But from Keycloak version 22+, the system changed completely:

Old “Attributes” tab disappeared

A new Declarative User Profile was introduced

Custom attributes no longer appear until you manually define them

In this guide, you’ll learn exactly how to enable and use custom user attributes in Keycloak for real-world applications like Laravel, Flutter apps, and microservices.

What Are Custom User Attributes?

Custom user attributes are extra fields you add to a Keycloak user profile.
Examples:

phone

phone_verified

user_type

country_code

domain

ms_user_id (Laravel DB foreign key)

role_level

business_category
Enter fullscreen mode Exit fullscreen mode

Why Keycloak Removed the Old Attributes Section (Keycloak 22–26)

Before Keycloak 21, you could directly add attributes on the user page.
Now Keycloak enforces Declarative User Profile, which means:

You must define allowed attributes

You must declare their permissions

Keycloak GUI will show the field only if it exists in the JSON schema

This gives Keycloak:

Better validation

Security control

Cleaner identity model

Rule-based attribute editing

Understanding the Declarative User Profile

Keycloak now uses a JSON-based configuration to manage user attributes.

This JSON controls


{
  "attributes": [
    {
      "name": "phone",
      "displayName": "Phone Number",
      "permissions": {
        "view": ["admin", "user"],
        "edit": ["admin", "user"]
      }
    }
  ],
  "groups": []
}
Enter fullscreen mode Exit fullscreen mode

Steps to Enable Custom User Attributes

Step 1 — Enable Declarative User Profile

Located at:

Realm Settings → User Profile → Enabled
Enter fullscreen mode Exit fullscreen mode

Once enabled, JSON editor becomes active.

Step 2 — Add the Custom Attribute to the JSON Schema

This JSON is mandatory for Keycloak to display the field.
Example:


{
  "attributes": [
    {
      "name": "phone",
      "displayName": "Phone Number",
      "permissions": {
        "view": ["admin", "user"],
        "edit": ["admin", "user"]
      }
    }
  ],
  "groups": []
}
Enter fullscreen mode Exit fullscreen mode

==============or=====================

{
  "attributes": [
    {
      "name": "username",
      "displayName": "Username",
      "validations": {},
      "annotations": {},
      "required": {
        "roles": [
          "user"
        ]
      },
      "permissions": {
        "view": [
          "admin",
          "user"
        ],
        "edit": [
          "admin",
          "user"
        ]
      },
      "multivalued": false
    },
    {
      "name": "email",
      "displayName": "Email",
      "validations": {
        "email": {}
      },
      "annotations": {},
      "required": {
        "roles": [
          "user"
        ]
      },
      "permissions": {
        "view": [
          "admin",
          "user"
        ],
        "edit": [
          "admin",
          "user"
        ]
      },
      "multivalued": false
    },
    {
      "name": "phone",
      "displayName": "Phone Number",
      "validations": {},
      "annotations": {},
      "permissions": {
        "view": [
          "admin",
          "user"
        ],
        "edit": [
          "admin",
          "user"
        ]
      },
      "multivalued": false
    },
    {
      "name": "status",
      "displayName": "Status",
      "validations": {},
      "annotations": {},
      "permissions": {
        "view": [
          "admin",
          "user"
        ],
        "edit": [
          "admin",
          "user"
        ]
      },
      "multivalued": false
    },
    {
      "name": "sms_notification",
      "displayName": "SMS Notification",
      "validations": {},
      "annotations": {},
      "permissions": {
        "view": [
          "admin",
          "user"
        ],
        "edit": [
          "admin",
          "user"
        ]
      },
      "multivalued": false
    },
    {
      "name": "email_notification",
      "displayName": "Email Notification",
      "validations": {},
      "annotations": {},
      "permissions": {
        "view": [
          "admin",
          "user"
        ],
        "edit": [
          "admin",
          "user"
        ]
      },
      "multivalued": false
    },
    {
      "name": "whatsapp_notification",
      "displayName": "WhatsApp Notification",
      "validations": {},
      "annotations": {},
      "permissions": {
        "view": [
          "admin",
          "user"
        ],
        "edit": [
          "admin",
          "user"
        ]
      },
      "multivalued": false
    },
    {
      "name": "role",
      "displayName": "Role",
      "validations": {},
      "annotations": {},
      "permissions": {
        "view": [
          "admin",
          "user"
        ],
        "edit": [
          "admin"
        ]
      },
      "multivalued": false
    },
    {
      "name": "address",
      "displayName": "Address",
      "validations": {},
      "annotations": {},
      "permissions": {
        "view": [
          "admin",
          "user"
        ],
        "edit": [
          "admin",
          "user"
        ]
      },
      "multivalued": false
    },
    {
      "name": "state",
      "displayName": "State",
      "validations": {},
      "annotations": {},
      "permissions": {
        "view": [
          "admin",
          "user"
        ],
        "edit": [
          "admin",
          "user"
        ]
      },
      "multivalued": false
    },
    {
      "name": "city",
      "displayName": "City",
      "validations": {},
      "annotations": {},
      "permissions": {
        "view": [
          "admin",
          "user"
        ],
        "edit": [
          "admin",
          "user"
        ]
      },
      "multivalued": false
    },
    {
      "name": "adhar",
      "displayName": "Aadhar Number",
      "validations": {},
      "annotations": {},
      "permissions": {
        "view": [
          "admin"
        ],
        "edit": [
          "admin"
        ]
      },
      "multivalued": false
    },
    {
      "name": "pinCode",
      "displayName": "PIN Code",
      "validations": {},
      "annotations": {},
      "permissions": {
        "view": [
          "admin",
          "user"
        ],
        "edit": [
          "admin",
          "user"
        ]
      },
      "multivalued": false
    },
    {
      "name": "profile_img",
      "displayName": "Profile Image URL",
      "validations": {},
      "annotations": {},
      "permissions": {
        "view": [
          "admin",
          "user"
        ],
        "edit": [
          "admin",
          "user"
        ]
      },
      "multivalued": false
    },
    {
      "name": "created_at",
      "displayName": "Created At",
      "validations": {},
      "annotations": {},
      "permissions": {
        "view": [
          "admin"
        ],
        "edit": [
          "admin"
        ]
      },
      "multivalued": false
    },
    {
      "name": "updated_at",
      "displayName": "Updated At",
      "validations": {},
      "annotations": {},
      "permissions": {
        "view": [
          "admin"
        ],
        "edit": [
          "admin"
        ]
      },
      "multivalued": false
    }
  ],
  "groups": []
}
Enter fullscreen mode Exit fullscreen mode

in above json 3 attribute
username

 {
      "name": "username",
      "displayName": "Username",
      "validations": {},
      "annotations": {},
      "required": {
        "roles": [
          "user"
        ]
      },
      "permissions": {
        "view": [
          "admin",
          "user"
        ],
        "edit": [
          "admin",
          "user"
        ]
      },
      "multivalued": false
    },
Enter fullscreen mode Exit fullscreen mode

email

{
      "name": "email",
      "displayName": "Email",
      "validations": {
        "email": {}
      },
      "annotations": {},
      "required": {
        "roles": [
          "user"
        ]
      },
      "permissions": {
        "view": [
          "admin",
          "user"
        ],
        "edit": [
          "admin",
          "user"
        ]
      },
      "multivalued": false
    },
Enter fullscreen mode Exit fullscreen mode

phone

{
  "name": "phone",
  "displayName": "Phone Number",
  "validations": {},
  "annotations": {},
  "permissions": {
    "view": ["admin", "user"],
    "edit": ["admin", "user"]
  },
  "multivalued": false
}

Enter fullscreen mode Exit fullscreen mode

Step 3 — Expose Custom Attributes in Token using Client Scope + Mappers
3.1 — Create a Custom Client Scope

Go to:

Realm → Client scopes → Create client scope
Enter fullscreen mode Exit fullscreen mode

Fill details:

Name: motoshare-user-profile
(ya koi bhi naam, bas yaad rehne layak ho)

Protocol: openid-connect

Click Save

3.2 — Add Mappers for Each Custom Attribute

Ab har custom attribute jo tumne User Profile JSON mein banaya hai
(jaise status, email_notification, address, adhar, etc.)
uske liye ek User Attribute mapper banana zaroori hai.

Jaise jaise attributes tumne JSON mein define kiye:

status

sms_notification

email_notification

whatsapp_notification

role

address

state

city

adhar

pinCode

profile_img

created_at

updated_at
Enter fullscreen mode Exit fullscreen mode

In sab ke liye:

Client scopes → open motoshare-user-profile

Tab Mappers → Click Create-->configure new mapper or click addmapper then by configuration
Enter fullscreen mode Exit fullscreen mode


===================or==============================


Set values:

Mapper Type: User Attribute

Name:
e.g. status

User Attribute:
same as attribute name → status

Token Claim Name:
same → status

Claim JSON Type: String
(agar multivalued kabhi use karein to JSON/String accordingly)


Toggle options:

Add to ID token: ON

Add to access token: ON

Add to userinfo: ON

Add to token introspection: ON

Multivalued: OFF
Enter fullscreen mode Exit fullscreen mode

Click Save
Ab tum is naye client scope ke detail page pe ho.

How Backend (Laravel / Node / Go) Sends Custom Attributes

Once you have created custom attributes in Realm Settings → User Profile (like phone, status, address, etc.), your backend has only one responsibility:

Send these attributes inside the attributes object when creating or updating a user in Keycloak.

  1. What Keycloak Expects from Any Backend

Keycloak’s Admin REST API accepts a JSON payload like this when creating or updating a user:

{
  "username": "rakesh",
  "email": "rakesh@example.com",
  "enabled": true,
  "attributes": {
    "phone": ["9876543210"],
    "status": ["active"],
    "address": ["Bokaro"],
    "role": ["user"]
  }
}
Enter fullscreen mode Exit fullscreen mode

Some important rules:

The keys inside attributes must be exactly equal to the “Attribute Name” in the Keycloak User Profile (phone, status, address, etc.).

Each attribute value is an array of strings, even if you store only one value:

✅ "phone": ["9876543210"]

❌ "phone": "9876543210"

The good news: this pattern is same for Laravel, Node, Go, Python, anything. Only the HTTP client syntax changes.

  1. Laravel Example – Full Flow to Send Custom Attributes

In Laravel, you already have a neat createUser() method. Let’s walk through it step by step and see where custom attributes go.

2.1. High-Level Flow

Get an admin token from Keycloak.

Check if user already exists in Keycloak by email.

If exists → build an update payload with only missing attributes.

If not exists → build a create payload with all attributes.

Call Keycloak Admin API with POST /users or PUT /users/{id}.

2.2. Main createUser() Method (Laravel)

public function createUser(
    string $username,
    string $email,
    string $fname,
    string $lname,
    string $password = null,
    string $phone = null,
    string $address = null,
    string $status = null,
    string $sms_notification = null,
    string $email_notification = null,
    string $whatsapp_notification = null,
    string $role = null,
    string $state = null,
    string $city = null,
    string $adhar = null,
    string $pinCode = null,
    string $profile_img = null,
    string $created_at = null,
    string $updated_at = null
): ?string {
    $token = $this->getmyAdminToken();
    if (!$token) {
        Log::error("❌ Cannot create user — admin token missing");
        return null;
    }

    if (!$password) {
        $password = Str::random(12);
    }

    $realmUrl = config('keycloak.base_url') . "/admin/realms/" . config('keycloak.realm');
    $usersUrl = $realmUrl . "/users";

    // 1️⃣ Check if user already exists
    $kcUser = $this->findKeycloakUserByEmail($token, $usersUrl, $email);

    if (!empty($kcUser)) {
        $kcUserId = $kcUser['id'];

        // 2️⃣ Build payload to update only missing attributes
        $updatePayload = $this->buildUpdatePayloadForExistingUser(
            $kcUser,
            $email,
            $phone,
            $address,
            $status,
            $sms_notification,
            $email_notification,
            $whatsapp_notification,
            $role,
            $state,
            $city,
            $adhar,
            $pinCode,
            $profile_img,
            $created_at,
            $updated_at
        );

        if (!empty($updatePayload)) {
            Log::info("📌 Updating existing KC user", [
                'kc_user_id' => $kcUserId,
                'payload'    => $updatePayload,
            ]);

            Http::withToken($token)->put("$usersUrl/$kcUserId", $updatePayload);

            Log::info("🟢 Existing KC user updated: $kcUserId");
        } else {
            Log::info("ℹ️ Existing KC user already has correct attributes", [
                'kc_user_id' => $kcUserId,
            ]);
        }

        return $kcUserId;
    }

    // 3️⃣ Create brand new user with all attributes
    $payload = $this->buildCreatePayload(
        $username,
        $email,
        $fname,
        $lname,
        $password,
        $phone,
        $address,
        $status,
        $sms_notification,
        $email_notification,
        $whatsapp_notification,
        $role,
        $state,
        $city,
        $adhar,
        $pinCode,
        $profile_img,
        $created_at,
        $updated_at
    );

    Log::info("📤 Creating new KC user", $payload);

    $response = Http::withToken($token)->post($usersUrl, $payload);

    if ($response->failed()) {
        Log::error("❌ KC create user failed: " . $response->body());
        return null;
    }

    // Location header contains the new user id
    $kcUserId = basename($response->header('Location'));

    Log::info("🟢 KC user created with ID: $kcUserId");

    // Optional: fetch full user to verify attributes
    $kcUser = Http::withToken($token)->get("$usersUrl/$kcUserId")->json();
    Log::info("🔍 KC User After Creation:", $kcUser);

    return $kcUserId;
}
Enter fullscreen mode Exit fullscreen mode

2.3. Building the Update Payload (Only Missing Attributes)

When user already exists, you don’t want to overwrite values blindly.
This helper compares each attribute and only sends the ones that are empty in Keycloak:

private function buildUpdatePayloadForExistingUser(
    array $kcUser,
    string $email,
    ?string $phone,
    ?string $address,
    ?string $status,
    ?string $sms_notification,
    ?string $email_notification,
    ?string $whatsapp_notification,
    ?string $role,
    ?string $state,
    ?string $city,
    ?string $adhar,
    ?string $pinCode,
    ?string $profile_img,
    ?string $created_at,
    ?string $updated_at
): array {
    $updatePayload = [];
    $updatePayload['email'] = $email;

    $currentAttributes = $kcUser['attributes'] ?? [];

    // Input attributes (keys must match Keycloak attribute names)
    $attributesInput = [
        'phone'                 => $phone,
        'address'               => $address,
        'status'                => $status,
        'sms_notification'      => $sms_notification,
        'email_notification'    => $email_notification,
        'whatsapp_notification' => $whatsapp_notification,
        'role'                  => $role,
        'state'                 => $state,
        'city'                  => $city,
        'adhar'                 => $adhar,
        'pinCode'               => $pinCode,
        'profile_img'           => $profile_img,
        'created_at'            => $created_at,
        'updated_at'            => $updated_at,
    ];

    foreach ($attributesInput as $key => $value) {
        if ($value === null || $value === '') {
            continue;
        }

        $currentValue = $currentAttributes[$key][0] ?? null;

        if (empty($currentValue)) {
            $updatePayload['attributes'][$key] = [$value];
        }
    }

    return $updatePayload;
}
Enter fullscreen mode Exit fullscreen mode

This keeps Keycloak as the source of truth and fills only missing data.

2.4. Building the Create Payload (Full Attribute Set)

For a new user, you usually want to send everything at once:

private function buildCreatePayload(
    string $username,
    string $email,
    string $fname,
    string $lname,
    string $password,
    ?string $phone,
    ?string $address,
    ?string $status,
    ?string $sms_notification,
    ?string $email_notification,
    ?string $whatsapp_notification,
    ?string $role,
    ?string $state,
    ?string $city,
    ?string $adhar,
    ?string $pinCode,
    ?string $profile_img,
    ?string $created_at,
    ?string $updated_at
): array {
    return [
        'username'  => $username,
        'email'     => $email,
        'enabled'   => true,
        'firstName' => $fname,
        'lastName'  => $lname,

        // 👇 All custom attributes here (array of strings)
        'attributes' => [
            'phone'                 => [$phone ?? ''],
            'address'               => [$address ?? ''],
            'status'                => [$status ?? ''],
            'sms_notification'      => [$sms_notification ?? ''],
            'email_notification'    => [$email_notification ?? ''],
            'whatsapp_notification' => [$whatsapp_notification ?? ''],
            'role'                  => [$role ?? ''],
            'state'                 => [$state ?? ''],
            'city'                  => [$city ?? ''],
            'adhar'                 => [$adhar ?? ''],
            'pinCode'               => [$pinCode ?? ''],
            'profile_img'           => [$profile_img ?? ''],
            'created_at'            => [$created_at ?? ''],
            'updated_at'            => [$updated_at ?? ''],
        ],

        'credentials' => [
            [
                'type'      => 'password',
                'value'     => $password,
                'temporary' => false,
            ],
        ],
    ];
}
Enter fullscreen mode Exit fullscreen mode

This is the exact place where your Laravel backend sends custom attributes to Keycloak.

  1. Reading Custom Attributes Back in Other Domains

Once you configure mappers in the client (or client scope) to include attributes like phone in the access token, your other apps / domains can read them directly from the token or from token/introspect.

Your middleware already does that:

$cookieHeader = $request->header('Cookie');
preg_match('/keycloak_access_token=([^;]+)/', $cookieHeader, $matches);
$accessToken = $matches[1] ?? null;

if ($accessToken) {
    $introspectUrl = env('KEYCLOAK_BASE_URL') . "/realms/" . env('KEYCLOAK_REALM') . "/protocol/openid-connect/token/introspect";

    $introspect = Http::asForm()->post($introspectUrl, [
        'token'         => $accessToken,
        'client_id'     => env('KEYCLOAK_CLIENT_ID'),
        'client_secret' => env('KEYCLOAK_CLIENT_SECRET'),
    ]);

    $data  = $introspect->json();
    $email = $data['email'] ?? null;

    // 👇 Claim name must match your mapper config
    $phone    = $data['phone'] ?? ($data['phone_number'] ?? null);
    $username = $data['preferred_username'] ?? null;

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

    $kcUserId = $data['sub'] ?? null;

    if ($email) {
        return $this->autoLoginLaravelUser($email, $phone, $kcUserId, $username, $request, $next);
    }
}
Enter fullscreen mode Exit fullscreen mode

So the full circle is:

Laravel API → sends custom attributes to Keycloak.

Keycloak → stores them and, via mappers, pushes them into tokens.
Enter fullscreen mode Exit fullscreen mode

Other microservices (Laravel, Node, Go) → read those claims and automatically log users in with the same identity.

How Custom Attributes Are Used in Real Applications

Common Errors and Why They Happen

Best Practices for Production

Never remove username or email attributes

Keep declarative profile clean and minimal

Use custom attributes for identity — NOT token mappers only

Log all Keycloak payloads for debugging

Sync Keycloak ID (kc_user_id) into your local DB

Use Token Exchange for multi-service login

Different way to check custom user attribute added in keycloack

first way

. Check in Keycloak Database (Advanced)

If you want to check in DB:

Run SQL:

SELECT 
    u.id AS kc_user_id,
    ua.name AS attribute_name,
    ua.value AS attribute_value
FROM user_entity u
LEFT JOIN user_attribute ua ON ua.user_id = u.id
WHERE u.email = 'theuser@example.com';
Enter fullscreen mode Exit fullscreen mode

Using laravel log

Use Keycloak API to fetch user after creation:

$checkUrl = $url . "/" . $kcUserId;
$kcUser = Http::withToken($token)->get($checkUrl)->json();

Log::info("🟢 KC User After Creation", $kcUser);
Enter fullscreen mode Exit fullscreen mode
public function createUser(string $username, string $email, string $fname, string $lname, string $password = null,string $phone = null): ?string
{
    // 1. Get admin token
    $token = $this->getmyAdminToken();
    if (!$token) {
        Log::error("❌ Cannot create user — admin token missing");
        return null;
    }

    // 2. Use phone or random string as password if none given
    if (!$password) {
        $password = Str::random(12);
    }

    // 3. Keycloak Admin API URL
    $url = config('keycloak.base_url') . "/admin/realms/" . config('keycloak.realm') . "/users";

    // 4. Payload with password + required actions
    $payload = [
        'username'        => $username,
        'email'           => $email,
        'enabled'         => true,
        'firstName'       => $fname,
        'lastName'        => $lname,

             'attributes' => [
            'phone' => [$phone ?? ''],
                   ],
        // OPTIONAL: required actions → uncomment if needed
        // 'requiredActions' => [
        //     "VERIFY_EMAIL",
        //     "UPDATE_PASSWORD",
        // ],

        // Set password for new user
        "credentials" => [
            [
                "type"      => "password",
                "value"     => $password,
                "temporary" => false  // ❗ false = user can use password immediately
            ]
        ]
    ];

    // 5. Make request
    $response = Http::withToken($token)->post($url, $payload);

    // 6. Error handling
    if ($response->failed()) {
        Log::error("❌ KC create user failed: " . $response->body());
        return null;
    }

    $location = $response->header('Location');
    $kcUserId = basename($location);



    $checkUrl = $url . "/" . $kcUserId;

    // LOG #3 → Calling KC user fetch API
    Log::info("🔍 Fetching KC user after creation from: $checkUrl");

    // Request user data
    $kcUser = Http::withToken($token)->get($checkUrl)->json();

    // LOG #4 → Full user record from KC


    Log::info("🟢 KC user created with ID: $kcUserId");

    return $kcUserId;
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)