Token and Claims Modeling in Keycloak: Reproducing Legacy IdP Behavior

George Thomas George Thomas 16 min read

Last updated: June 2026

TL;DR

When you replace a legacy IdP with Keycloak, your apps do not care about the new architecture. They care that the exact claims they read yesterday show up in the exact same shape tomorrow. Reproducing a legacy IdP’s token shape in Keycloak is a claim-mapping job, not a rewrite, and you can get it close to byte-compatible. The short version:

  • Write the claim contract first. Before you map anything, document every claim each app consumes: name, type, shape, and source. This document is the migration. Everything else is execution.
  • Protocol mappers do the reproduction. User attribute, hardcoded, group/role, and audience mappers cover almost every legacy behavior. The script mapper covers the rest, but it is disabled by default in Keycloak 26 and you deploy it as a JAR.
  • Token shape is the trap. Flat scope strings vs arrays, nested role objects, and namespaced claims like https://yourapp/roles all matter to a parser that was written years ago. Match the shape, not just the value.
  • Lifecycle parity is part of the contract. Access and refresh lifetimes, offline tokens, and revocation behavior differ from your old IdP. Apps have silent assumptions baked into those numbers.
  • Diff before you cut over. Decode an old token and a candidate Keycloak token side by side and compare them against the contract. Cut over only when they match.

We run managed Keycloak for teams doing exactly these migrations, so this is the same approach we walk customers through.

Quick scope note before we go further. If you just need the basics of adding a single custom claim to a Keycloak token, start with our guide on using custom user attributes in Keycloak OIDC tokens. This post is the migration sibling: how to make a whole token byte-compatible with a legacy IdP your apps already trust, so nothing breaks on cutover.

Why does token shape break migrations?

Token shape breaks migrations because resource servers parse claims literally, and a parser written against your old IdP three years ago has no tolerance for drift. The Keycloak project is on the 26.x line (26.6 shipped in 2026), and out of the box it emits clean, spec-compliant OIDC tokens. The problem is that “spec-compliant” and “what your app reads” are rarely the same string.

Here is the failure pattern, and we have watched it play out more than once. The realm migration is flawless. Users authenticate. The login page looks right. Then an API gateway returns 403 on every request because it expected roles as a flat array of strings and Keycloak handed it a nested realm_access.roles object. Or a Spring resource server throws a null pointer because it read user_name and Keycloak only emits preferred_username. The auth worked. The token was the wrong shape.

Legacy IdPs each speak their own dialect. UAA emits user_name, authorities, zid, and cid. SiteMinder Federation emits whatever attribute names its admins configured. Okta and Cognito wrap roles and groups in their own namespaced or nested structures. Your apps grew up reading those exact shapes. The migration job is to make Keycloak speak the dialect your apps already know, not to convince your apps to learn Keycloak’s.

When apps consume tokens identically across IdPs, the cutover becomes a config flip per app rather than a code change per team. That single property is the difference between a quiet wave-based migration and a quarter of incident tickets, which is the whole argument for the dual-run migration approach this post supports.

Which protocol mapper reproduces which legacy behavior?

Keycloak protocol mappers are the components that decide what goes into a token and in what shape, and a small set of them reproduces almost every legacy IdP behavior you will hit. Per the Keycloak server administration guide, mappers attach to clients or client scopes and write claims into the access token, ID token, or userinfo response. Knowing which mapper does which job is most of the battle.

Here is the taxonomy that matters for migration, framed by the legacy behavior each one recreates.

  • User Attribute / User Property mapper. The workhorse. Reads a user attribute or a built-in property (username, email) and writes it under any claim name you choose. This reproduces most legacy “we put the username at user_name” behavior. UAA’s user_name is a User Property mapper on username. Any custom profile field a legacy IdP surfaced becomes a User Attribute mapper.
  • Hardcoded Claim mapper. Writes a fixed value into every token. Use it for tenant identifiers, issuer-style markers, or legacy constants like UAA’s zid that your apps read but that never actually vary per user in your setup.
  • Group Membership and Role mappers. Reproduce role and group claims. The Group Membership mapper emits group paths; the User Realm Role and User Client Role mappers emit role lists. This is where you recreate UAA’s authorities or a legacy groups claim. Critically, both let you control the claim name and whether the value is multivalued (an array) or not.
  • Audience mapper. Sets the aud claim. Legacy IdPs often stuffed scopes or resource identifiers into the audience. Keycloak builds aud from dedicated audience mappers and client scopes, so you reproduce the legacy audience contract explicitly, per resource server.
  • Script mapper. The escape hatch for transformations the declarative mappers cannot express: concatenating fields, conditional logic, reshaping an array. Read the caveat below before you reach for it.

A precise note on the script mapper, because the internet is full of stale advice here. The scripts feature in Keycloak is a preview/disabled-by-default capability. You enable it with the scripts feature flag, and in production you deploy your scripts packaged as a JAR rather than pasting JavaScript into the admin console (console-based scripts are disabled by default for security). Treat the script mapper as a last resort. If a User Attribute, Hardcoded, Role, or Audience mapper can express the claim, use it, because declarative mappers are easier to review, version, and reason about than a blob of JavaScript that only one engineer understands.

Keycloak’s protocol mappers reproduce most legacy IdP token behavior through five core types: User Attribute mappers for profile fields, Hardcoded mappers for constants, Group and Role mappers for authorization claims, Audience mappers for the aud contract, and the disabled-by-default Script mapper for transformations, per the Keycloak protocol mapper documentation.

How do you design a claim contract before migrating?

You design a claim contract by inventorying every claim each application reads, then documenting its name, type, shape, source, and which token carries it, before you touch a single mapper. This document is the centerpiece of the whole migration. In our experience running these projects, the teams that write the contract first finish quietly, and the teams that skip it debug 403s in production for a month.

The contract is not optional paperwork. It is the spec that every protocol mapper gets validated against. Without it, “did the migration work?” has no answer, because nobody wrote down what working looks like. With it, the test is mechanical: decode the new token, compare each claim to the row in the table, pass or fail.

Build the contract by interrogating the apps, not the old IdP’s config. The config tells you what the IdP can emit. Only the app code tells you what it actually reads. Grep resource servers for claim names, check gateway and middleware policies, and ask each app team the one question that matters: which claims does your code read, and what happens if one is missing or the wrong shape?

Here is the shape of the contract table we use. One row per claim per consuming app.

App Claim name Type Shape Source (legacy) Source (Keycloak) Token Required?
orders-api user_name string scalar UAA username User Property mapper on username access yes
orders-api authorities array ["orders.admin","uaa.user"] UAA authorities User Realm Role mapper, multivalued access yes
billing-svc roles array flat string array Okta groups User Realm Role mapper, claim name roles access yes
billing-svc https://billing/tenant string scalar, namespaced Okta custom claim Hardcoded or User Attribute mapper ID yes
reports-ui department string scalar SiteMinder Department attr User Attribute mapper ID no
gateway aud array ["orders","billing"] UAA scope-derived aud Audience mappers per resource access yes

The columns that bite people are Shape and Token. Shape is where flat-vs-nested and string-vs-array live, and a mismatch there fails silently in a way a value mismatch never does. Token matters because a claim in the ID token is invisible to a resource server validating the access token, and “I added the mapper but the API still 403s” is almost always a claim landing in the wrong token. Fill in both columns deliberately for every row, or you will fill them in later under incident pressure.

A documented claim contract, one row per claim per app capturing name, type, shape, source, and target token, converts an IAM migration from a guessing game into a mechanical diff. Teams that author the contract before mapping consistently report quieter cutovers, because every protocol mapper is validated against a written spec rather than against production traffic.

How do you recreate a legacy IdP’s exact token format?

You recreate a legacy token format by matching three things per claim: the exact claim name, the value type, and the structural shape (scalar, flat array, or nested object). Keycloak’s mappers expose all three through the claim name field, the jsonType.label setting, and the multivalued toggle. Value parity is easy. Shape parity is where migrations are won or lost, because a parser that expects an array will choke on a string even when the data is identical.

Start with the names, because they are the cheapest to get right and the most common to get wrong. Keycloak emits preferred_username; UAA apps read user_name. Keycloak nests roles under realm_access.roles; your old IdP may have emitted a top-level roles or authorities. You override the claim name on every mapper, so set it to exactly what the app reads. Do not ask the app to change. The app changing is the thing you are trying to avoid.

Flat scope strings vs arrays

Scope representation is a classic divergence. Some legacy IdPs and OAuth servers emit scope as a single space-delimited string ("openid orders.read orders.write"), which is what the OAuth spec actually prescribes for the scope parameter. Others, and some app parsers, expect an array. Keycloak emits scope as a space-delimited string by default, matching the spec. If a legacy app was written against an IdP that handed it an array, you have two choices: fix the app’s parser (correct but it is code change you wanted to avoid) or reshape the claim with a script mapper (last resort). Document which apps expect which, because this one is invisible until a parser silently reads the first scope only.

Nested role arrays vs flat arrays

Keycloak’s default role structure is nested: realm_access.roles and resource_access.<client>.roles. Plenty of legacy apps expect a flat top-level roles or authorities array of strings. Reproduce the flat shape with a User Realm Role or User Client Role mapper: set the token claim name to roles (or authorities), enable Multivalued, and set the JSON type to String. That writes a flat array under the name the app reads, alongside or instead of Keycloak’s nested default. If an app needs the nested Okta or Cognito shape instead, model it deliberately rather than hoping the default lines up.

Custom namespaced claims

Okta, Auth0-style, and many enterprise IdPs emit namespaced custom claims like https://yourapp.example.com/roles or https://yourapp.example.com/tenant. Keycloak is perfectly happy to use a full URI as a claim name. Put the URI in the mapper’s claim name field exactly as the app expects, including the scheme and path. The mapper does not care that the name looks like a URL; it is just a string key in the JSON. This is how you reproduce an Okta token’s namespaced contract without changing the consuming app.

Legacy claim names by IdP

The exact names depend on where you are coming from. A few you will likely meet:

  • UAA: user_name, user_id, authorities (on client-credentials tokens), scope (on user tokens), origin, zid, cid, client_id, grant_type. We cover these claim by claim in the Cloud Foundry UAA to Keycloak migration guide.
  • SiteMinder: whatever the Federation admins named the assertion attributes (SM_USER lives in headers, not the token, but assertion attributes like EmailAddress, Department, and group attributes become claims). You map these in via Attribute Importer first, then a protocol mapper, as covered in the SiteMinder federation guide and the attribute mapping during brokering guide.
  • Okta / Cognito: namespaced custom claims, a groups claim (sometimes flat, sometimes filtered by a regex in the authorization server), and cognito:groups / cognito:username style prefixed claims in Cognito’s case. Reproduce the prefix literally in the claim name.

Reproducing a legacy token requires matching claim name, type, and structural shape, not just value: Keycloak exposes all three via the claim name field, jsonType.label, and the multivalued toggle, so a flat authorities array or a namespaced https://app/roles claim is recreatable per the Keycloak protocol mapper documentation.

How do you match token lifetimes and revocation behavior?

You match token lifecycle by auditing the legacy IdP’s access lifetime, refresh lifetime, offline token policy, and revocation semantics, then setting Keycloak’s equivalents per realm and per client. Lifetimes are part of the claim contract whether you wrote them down or not, because apps and batch jobs encode silent assumptions in them. “The sync runs every 50 minutes because the token lives an hour” is a real dependency, and a shorter Keycloak lifetime turns it into a 401 storm.

Keycloak controls token lifetimes at the realm level and overrides them per client. Access Token Lifespan, SSO Session Idle, SSO Session Max, and the offline session settings live in realm settings, and a client’s advanced settings can override the access token lifespan for that client specifically. Audit every legacy client’s access and refresh validity, then reproduce them. Keycloak defaults to a short access token (around five minutes) which is often shorter than legacy IdPs ran, so do not assume the default is safe for an app that expected an hour.

Offline tokens are the lifecycle feature most likely to surprise you. UAA and several legacy IdPs issue long-lived refresh tokens for headless and batch use. Keycloak models this through offline access: a client requests the offline_access scope and receives an offline token that survives user session expiry. If your legacy IdP gave batch jobs effectively immortal refresh tokens, the Keycloak parallel is an offline token, not a regular refresh token, and you have to grant the scope explicitly. For a deeper treatment of lifetime tuning, our JWT token lifecycle guide covers expiration and refresh strategy.

Revocation is the part that genuinely differs, and you should expect it to. Legacy IdPs vary wildly: some use opaque tokens checked on every request (immediate revocation), some use stateful introspection, some use stateless JWTs that cannot be revoked before expiry. UAA’s /check_token introspection has different semantics from Keycloak’s RFC 7662 token introspection endpoint. Keycloak supports both stateless JWT validation (fast, but a revoked token is valid until it expires) and introspection (slower, but allows revocation checks). If your old IdP revoked instantly via opaque-token introspection and your apps relied on that for logout-kills-session behavior, replicate it with Keycloak introspection or a short access lifetime plus refresh revocation. Do not silently downgrade a security property because the default was convenient.

Token lifecycle is part of the migration contract: audit the legacy access lifetime, refresh lifetime, offline token policy, and revocation model, then reproduce each in Keycloak’s realm and client settings. Keycloak’s default access token is roughly five minutes, often shorter than legacy IdPs, per the Keycloak token documentation, so explicit lifetime parity prevents silent 401 storms in batch jobs.

How do you test that the new token matches the contract?

You test by decoding a real legacy token and a candidate Keycloak token side by side, then diffing every claim against the contract before any app cuts over. The verification step matters more than the mapping step, because a mapper that looks right in the console can still land a claim in the wrong token or the wrong shape. The contract turns testing into a checklist instead of a vibe.

Here is the workflow we run, and it is deliberately boring.

  1. Capture a baseline. Pull a real token from the legacy IdP for a representative user, for each grant type your apps use (authorization code, client credentials, refresh). Grant type matters: UAA puts authorities on client-credentials tokens and scope on user tokens, so a single sample lies to you.
  2. Mint the candidate. Authenticate the same user against the new Keycloak realm with the mappers in place, and capture the corresponding token for the same grant type.
  3. Decode both and diff. Paste each token into our free JWT token analyzer and compare claim by claim. It runs entirely in your browser, so no token leaves your machine, which matters when the token is a real production credential.
  4. Check against the contract, not against each other. For every row in the contract table, confirm the claim name, type, shape, target token, and required-ness. A claim that matches the old token but is not in the contract is noise; a claim that matches the contract but not the old token means the contract was wrong, and you fix the document.
  5. Diff the shape, not just the keys. The keys can match while the values are subtly wrong: a string where the app wants an array, a nested object where it wants flat, a namespaced key with a typo in the URI. This is the failure that decodes fine and 403s in production.

Automate this once it works manually. The same decode-and-diff is scriptable in your CI: mint a token, parse it, assert each contract row. Run it on every mapper change so a well-meaning tweak to one client scope does not quietly break the audience claim for a different app. If a custom attribute you expected is missing from the token entirely, our guide on why a Keycloak custom attribute does not appear in the token covers the usual culprits (unmapped scope, wrong token, user profile not populated).

The reliable cutover test is a claim-by-claim diff of a real legacy token against a candidate Keycloak token, validated against the written contract rather than against each other. A browser-based JWT analyzer keeps production credentials local during the diff, and the same assertion logic scripts cleanly into CI so mapper changes cannot silently break a downstream app.

Frequently asked questions

How do I add custom claims in Keycloak?

You add custom claims with protocol mappers attached to a client or a client scope, choosing the mapper type that matches your data source: User Attribute for profile fields, Hardcoded for constants, Role and Group mappers for authorization data. Each mapper lets you set the claim name, JSON type, and target token. For the full single-claim walkthrough, see our custom user attributes in Keycloak tokens guide.

How do I make Keycloak tokens match my old IdP?

Write a claim contract documenting every claim each app reads (name, type, shape, source, token), then reproduce each one with the matching protocol mapper and override the claim name to exactly what the app expects. Match the structural shape, not just the value, since a flat array vs a nested object fails silently. Finally, diff a real old token against a candidate Keycloak token with a JWT analyzer before cutover.

What are Keycloak protocol mappers?

Protocol mappers are Keycloak components that decide what data goes into a token and how it is shaped. They attach to clients or client scopes and write claims into the access token, ID token, or userinfo response. The core types are User Attribute, User Property, Hardcoded, Group Membership, Role, and Audience mappers, plus a disabled-by-default Script mapper for transformations the declarative mappers cannot express.

How do I change token lifetimes in Keycloak?

Set token lifetimes in realm settings under Tokens (Access Token Lifespan, SSO Session Idle, SSO Session Max) and override the access token lifespan per client in the client’s advanced settings. For long-lived headless tokens, grant the offline_access scope to issue offline tokens that survive session expiry. Audit your legacy IdP’s lifetimes first, because batch jobs often encode silent assumptions in them.

Is the Keycloak script mapper safe to use in production?

It works, but treat it as a last resort. The scripts feature is disabled by default in Keycloak 26 and you enable it with a feature flag. For production, deploy scripts packaged as a JAR rather than pasting JavaScript into the admin console, since console-authored scripts are disabled by default for security reasons. Prefer declarative mappers (User Attribute, Hardcoded, Role, Audience) wherever they can express the claim, because they are far easier to review and version.

How do I reproduce a legacy IdP’s flat roles array in Keycloak?

Keycloak nests roles under realm_access.roles by default. To emit a flat top-level array like roles or authorities, add a User Realm Role or User Client Role mapper, set the token claim name to what the app reads, enable Multivalued, and set the JSON type to String. That produces a flat string array under the legacy name. Verify the result by decoding the token and confirming the shape matches your contract row.

Summary

Replacing a legacy IdP with Keycloak is a token-shape problem dressed up as a platform migration. Your apps read specific claims in specific shapes, and the whole job is making Keycloak emit those exact claims so the cutover is a config flip, not a code change. Write the claim contract first: every claim, every app, with name, type, shape, source, and target token. Reproduce each claim with the right protocol mapper, matching name and structure, not just value. Match the token lifetimes and revocation behavior your apps silently depend on. Then decode old and new tokens side by side and diff them against the contract until they match. Get that right and the migration is boring, which in IAM is the highest possible compliment.

The part that drains your team

The claim mapping takes a sprint. Operating a hardened, highly available Keycloak alongside the legacy IdP for the length of a wave-based migration is the part that quietly eats your roadmap: HA, upgrades, patching, monitoring, and on-call for a brand-new identity platform at exactly the moment your team is busy reproducing token contracts and cutting apps over.

That part is outsourceable. Start by decoding your old and new tokens with our free JWT token analyzer, and if you would rather not babysit the new IdP, Skycloak runs managed Keycloak with an enterprise SLA, upgrades, and on-call handled, so your team spends the migration reproducing claims instead of operating a platform. Planning a legacy IdP exit? Talk to us and we will sanity-check your claim contract for free.

George Thomas
Written by George Thomas Senior IAM Engineer

George is a senior IAM engineer with 23+ years in software engineering, including 14+ years specializing in identity and access management. He designs and modernizes enterprise IAM platforms with deep expertise in Keycloak, OAuth 2.0, OpenID Connect, SAML, and identity federation across cloud and hybrid environments. Previously at Trianz and a long-term contributor to Entrust IAM product engineering, George authors Skycloak's technical Keycloak tutorials.

Ready to simplify your authentication?

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

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