Skip to main content
Unlisted page
This page is unlisted. Search engines will not index it, and only users having a direct link can access it.

Porting Azure AD B2C custom policies to Keycloak

· 15 min read
Jeff Patzer
Phase Two

Most of an Azure AD B2C custom policy maps onto Keycloak configuration rather than code. A user journey becomes an authentication flow, orchestration steps become executions, preconditions become conditional subflows, the claims schema becomes the declarative user profile, and page layouts become themes. None of that is Java.

Three things are not configuration. RESTful technical profiles called mid-journey become a custom Authenticator. Long claims-transformation chains become either several protocol mappers or one script mapper — and the script mapper is a JAR you build, not JavaScript you paste into the console, which surprises people. And orchestration that branches on a computed value rather than on the presence of a claim has no declarative equivalent at all.

Everything below was run against Keycloak 26.8.1 in start-dev. The Java compiles against 26.8.1; every HTTP status and response body is copied from an actual run.

The short version​

Azure AD B2CKeycloak 26.8Code?
<UserJourney>Authentication flowNo
<OrchestrationStep>Execution, or a subflowNo
<Precondition>CONDITIONAL subflow + a condition executionNo
Self-asserted technical profileBuilt-in form authenticator + user profileNo
OIDC/SAML technical profileIdentity providerNo
AAD read/write technical profileKeycloak's own user storeNo
RESTful technical profileAuthenticator SPIYes
<ClaimsSchema> / <ClaimType>Declarative user profileNo
<ClaimsTransformation>Protocol mapperNo
Chained transformationsScript mapper (a deployed JAR)Yes
<ContentDefinition> / LoadUriKeycloak themeNo
<LocalizedResources>Theme message bundlesNo
<RelyingParty> output claimsClient scopes + protocol mappersNo

Work down that table against your own TrustFrameworkExtensions.xml before estimating anything. The count of rows in the "Yes" column is your project.

User journeys become authentication flows​

A B2C user journey is an ordered list of orchestration steps, each referencing a technical profile, each optionally guarded by a precondition. A Keycloak authentication flow is an ordered list of executions, each referencing an authenticator, each with a requirement, and subflows can be guarded by conditions. The shapes match closely enough that you can usually transcribe one into the other on paper first.

Built-in flows are read-only, so you start by copying one:

kcadm.sh create authentication/flows/browser/copy -r b2c -s newName=b2c-journey

Then add a subflow for each orchestration step that needs a guard, and the executions inside it:

# The orchestration step
kcadm.sh create authentication/flows/b2c-journey/executions/flow -r b2c \
-s alias=step-mfa -s type=basic-flow -s provider=registration-page-form \
-s description='OrchestrationStep with Precondition'

# Its precondition, then the technical profile it guards
kcadm.sh create authentication/flows/step-mfa/executions/execution -r b2c \
-s provider=conditional-user-attribute
kcadm.sh create authentication/flows/step-mfa/executions/execution -r b2c \
-s provider=auth-otp-form

Read the flow back and you get this — the copied browser flow with the new subflow at the bottom:

Cookie ALTERNATIVE auth-cookie
Kerberos DISABLED auth-spnego
Identity Provider Redirector ALTERNATIVE identity-provider-redirector
b2c-journey Organization ALTERNATIVE -
b2c-journey Browser - Conditional Organization CONDITIONAL -
Condition - user configured REQUIRED conditional-user-configured
Organization Identity-First Login ALTERNATIVE organization
b2c-journey forms ALTERNATIVE -
Username Password Form REQUIRED auth-username-password-form
b2c-journey Browser - Conditional 2FA CONDITIONAL -
Condition - user configured REQUIRED conditional-user-configured
Condition - credential REQUIRED conditional-credential
OTP Form ALTERNATIVE auth-otp-form
WebAuthn Authenticator DISABLED webauthn-authenticator
Recovery Authentication Code Form DISABLED auth-recovery-authn-code-form
step-mfa DISABLED -
Condition - user attribute DISABLED conditional-user-attribute
OTP Form DISABLED auth-otp-form

Two things in that output matter. The first is that everything you just created arrived DISABLED — Keycloak does not infer a requirement from the fact that you added an execution, and a flow that silently does nothing is the usual first failure. The second is that the stock flow already demonstrates the pattern you want: a CONDITIONAL subflow whose first children are conditions and whose remaining children only run if they pass.

Set the requirements as a separate step. The requirement model has four values and some non-obvious interactions between them — building a custom authentication flow walks through the five ways it fails quietly, which is worth reading before you transcribe a twelve-step journey.

Preconditions become conditions​

B2C preconditions come in two useful flavours: ClaimsExist and ClaimEquals, with ExecuteActionsIf inverting them. Keycloak ships seven condition executions, and this is the complete list on 26.8.1:

conditional-client-scope
conditional-credential
conditional-level-of-authentication
conditional-sub-flow-executed
conditional-user-attribute
conditional-user-configured
conditional-user-role

The mapping is mostly direct. ClaimsExist on a user attribute is conditional-user-attribute. ClaimEquals is the same condition with a value configured. A precondition on whether the user has MFA set up is conditional-user-configured. A precondition on the requested authentication context is conditional-level-of-authentication. A precondition that depends on an earlier step having run is conditional-sub-flow-executed.

What is not there is a condition on an arbitrary expression. If your precondition tests a claim produced by a transformation two steps earlier, there is no declarative equivalent — you either persist the value as a user attribute first and test it with conditional-user-attribute, or you write a condition in Java. Persisting it is almost always the better answer, because it is also the thing that makes the journey debuggable.

The claims schema becomes the declarative user profile​

A <ClaimType> with a <DataType>, a <UserInputType> and a predicate validation maps onto one attribute in the realm's user profile. This is a closer match than it first appears: Keycloak's user profile carries display names, input types, per-role view and edit permissions, required-ness, and validators.

curl -X PUT "$KC/admin/realms/b2c/users/profile" -H "$A" -H 'Content-Type: application/json' -d '{
"attributes":[
{"name":"username","permissions":{"view":["admin","user"],"edit":["admin","user"]}},
{"name":"email","required":{"roles":["user"]},
"permissions":{"view":["admin","user"],"edit":["admin","user"]}},
{"name":"firstName","permissions":{"view":["admin","user"],"edit":["admin","user"]}},
{"name":"lastName","permissions":{"view":["admin","user"],"edit":["admin","user"]}},
{"name":"extension_loyaltyId","displayName":"Loyalty ID","multivalued":false,
"required":{"roles":["user"]},
"validations":{"pattern":{"pattern":"^[A-Z]{2}[0-9]{8}$","error-message":"loyaltyId-invalid"},
"length":{"min":10,"max":10}},
"permissions":{"view":["admin","user"],"edit":["admin","user"]},
"annotations":{"inputType":"text"}}
]}'
# HTTP 200

The validators are enforced server-side, on the Admin API as well as on the login forms, which is the part B2C predicates do not quite give you. A user that violates the pattern is rejected with the field named:

HTTP 400
{"errors":[
{"field":"extension_loyaltyId","errorMessage":"error-invalid-length","params":["extension_loyaltyId",10,10]},
{"field":"extension_loyaltyId","errorMessage":"loyaltyId-invalid","params":["extension_loyaltyId","^[A-Z]{2}[0-9]{8}$"]}]}

Note that B2C's extension_ prefix is just a string here — keep the names identical to the B2C ones during the migration and rename later, if at all. Reconciling two systems is hard enough without a translation layer in the middle.

Declare every attribute before you import anything. An attribute the realm does not know about is silently discarded on write, and the write still succeeds. That behaviour and the four unmanagedAttributePolicy values are covered properly in managed vs unmanaged attributes; it is the single most expensive thing to discover late in a B2C migration.

Technical profiles: four kinds, four destinations​

"Technical profile" is one word covering four unrelated jobs. Separate them before you map them.

Self-asserted profiles collect input from the user. In Keycloak these are form authenticators plus the user profile above — the fields rendered on the login, registration and update-profile pages come from the profile configuration, so a self-asserted technical profile usually disappears into configuration you have already written.

Azure Active Directory profiles (AAD-UserReadUsingObjectId, AAD-UserWriteUsingLogonEmail and friends) read and write the B2C directory. Keycloak has its own user store, so these have no equivalent and need none. They vanish.

OpenID Connect and SAML profiles federate to another IdP. These become identity providers — configuration, one per provider, with mappers to pull claims onto the user:

kcadm.sh create identity-provider/instances -r b2c \
-s alias=partner-idp -s providerId=oidc -s enabled=true \
-s 'config.authorizationUrl=https://idp.example.com/authorize' \
-s 'config.tokenUrl=https://idp.example.com/token' \
-s 'config.jwksUrl=https://idp.example.com/keys' \
-s 'config.useJwksUrl=true' -s 'config.clientId=...' -s 'config.clientSecret=...'

kcadm.sh create identity-provider/instances/partner-idp/mappers -r b2c \
-s name=loyalty -s identityProviderAlias=partner-idp \
-s identityProviderMapper=oidc-user-attribute-idp-mapper \
-s 'config.claim=extension_loyaltyId' \
-s 'config.user.attribute=extension_loyaltyId' \
-s 'config.syncMode=INHERIT'

Worth pausing here, because this is where the comparison with the other destination gets sharp. Microsoft's B2C-to-External-ID migration guide says that "third-party identity providers configured through B2C custom policies aren't supported" in External ID. In Keycloak a third-party IdP is four lines of configuration and there is no supported-list to be on.

RESTful profiles are the ones that cost you. They need an authenticator.

RESTful technical profiles become an Authenticator​

A RestfulProvider technical profile called from an orchestration step does three things: send some input claims to an HTTP endpoint, receive output claims, and fail the journey on a non-success response. That is exactly an Authenticator.

The whole surface is two interfaces and a service file. Here is the core of it — the full class compiles against Keycloak 26.8.1 as a provided-scope Maven build:

@Override
public void authenticate(AuthenticationFlowContext context) {
Map<String, String> cfg = context.getAuthenticatorConfig() == null
? Map.of() : context.getAuthenticatorConfig().getConfig();
UserModel user = context.getUser();

try {
// InputClaims: what the technical profile would have sent.
String body = JsonSerialization.writeValueAsString(Map.of(
"email", user.getEmail() == null ? "" : user.getEmail(),
"objectId", user.getFirstAttribute("b2c_object_id") == null
? "" : user.getFirstAttribute("b2c_object_id")));

HttpResponse<String> res = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5)).build()
.send(HttpRequest.newBuilder(URI.create(cfg.get("endpointUrl")))
.timeout(Duration.ofSeconds(10))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body)).build(),
HttpResponse.BodyHandlers.ofString());

// A technical profile that returns a conflict fails the journey. So does this.
if (res.statusCode() == 409) {
context.failure(AuthenticationFlowError.INVALID_USER);
return;
}
if (res.statusCode() / 100 != 2) {
// Not "the answer is no" -- "we did not get an answer". Different outcome.
context.failure(AuthenticationFlowError.INTERNAL_ERROR);
return;
}

// OutputClaims: persist what came back so a protocol mapper can emit it.
Map<String, Object> out = JsonSerialization.readValue(res.body(), Map.class);
Object claim = out.get(cfg.getOrDefault("outputClaim", "membershipTier"));
if (claim != null) {
user.setSingleAttribute(cfg.getOrDefault("outputClaim", "membershipTier"),
String.valueOf(claim));
}
context.success();
} catch (Exception e) {
context.failure(AuthenticationFlowError.INTERNAL_ERROR);
}
}

The distinction in the middle is the one to copy deliberately. A 409 and a timeout are not the same result. B2C's UserMessageIfClaimsPrincipalDoesNotExist metadata encourages you to treat every non-200 as a user-facing "no"; in a flow that gates access, conflating "your API said no" with "your API did not answer" is how an outage becomes a lockout, or worse, an accidental allow.

Implement AuthenticatorFactory on the same class, expose the endpoint URL and the output claim as ProviderConfigProperty entries so the step is configurable per realm, register it in META-INF/services/org.keycloak.authentication.AuthenticatorFactory, and drop the JAR in providers/. It then appears in the execution picker next to the 42 built-in authenticators and takes per-execution configuration the way a built-in does.

Claims transformations become protocol mappers​

Most transformations are not transformations at all, they are claim plumbing, and Keycloak has 39 protocol mappers for exactly that. CopyClaim into the token is a user-attribute mapper. CreateStringClaim with a constant is a hardcoded-claim mapper.

# CopyClaim: extension_loyaltyId -> loyalty_id
kcadm.sh create clients/$CID/protocol-mappers/models -r b2c \
-s name=loyalty-id -s protocol=openid-connect \
-s protocolMapper=oidc-usermodel-attribute-mapper \
-s 'config."user.attribute"=extension_loyaltyId' \
-s 'config."claim.name"=loyalty_id' -s 'config."jsonType.label"=String' \
-s 'config."id.token.claim"=true' -s 'config."access.token.claim"=true'

# CreateStringClaim: a constant
kcadm.sh create clients/$CID/protocol-mappers/models -r b2c \
-s name=tenant -s protocol=openid-connect \
-s protocolMapper=oidc-hardcoded-claim-mapper \
-s 'config."claim.name"=tid' -s 'config."claim.value"=contoso' \
-s 'config."jsonType.label"=String' -s 'config."id.token.claim"=true'

Both return HTTP 201. Put them on a shared client scope rather than per-client if more than one application needs the claim — that is the <RelyingParty> output-claims list, expressed once.

The script-mapper trap​

For the transformations that genuinely compute something — string formatting, conditionals, concatenating three claims into one — the documented answer is a script mapper. It does not work the way the documentation implies.

Starting Keycloak with --features=scripts and creating a script mapper through the Admin API gives you this:

HTTP 404
{"error":"ProtocolMapper provider not found"}

The feature is enabled — SCRIPTS shows up in the server's feature list — and the mapper provider still is not registered. Script mappers are deployed, not authored. You package the JavaScript in a JAR with a META-INF/keycloak-scripts.json descriptor naming it, drop the JAR in providers/, and only then does the mapper appear. There is no console path and no API path that takes a script body.

That is a reasonable security decision — arbitrary server-side JavaScript posted over the Admin API would be remote code execution with extra steps — but it changes the estimate. A script mapper is a build artifact on the same footing as the Authenticator above: same JAR, same pipeline, same release process. If you were counting on script mappers to avoid writing Java, count again; you are writing a deployable either way, and Java is easier to test.

Our practical advice is to avoid script mappers for anything you can express as a user attribute. Compute the value where it is produced — in the Authenticator that called the REST endpoint, or in the application that owns the data — persist it as an attribute, and emit it with a plain attribute mapper. It is debuggable, it survives upgrades, and it does not need a feature flag.

Page layouts become themes​

B2C's <ContentDefinition> points LoadUri at HTML you host, and B2C injects its form into a container element. Keycloak inverts this: you override the templates themselves. Keycloak 26.8 has six theme types:

account admin common email login welcome

The login theme is the one that corresponds to your content definitions, and email picks up the verification and password-reset messages that B2C sends through a third-party provider via custom policy. <LocalizedResources> becomes message bundles — messages_en.properties, messages_de.properties — in the same theme.

This is more capable than what you have, and it is also more work, because you are theming real templates rather than styling an injected form. Do not schedule the theme work inside the migration window. Ship with a lightly branded stock theme, cut over, then do the design pass. The one thing to get right on day one is the email theme, because those messages go to users during cutover.

What genuinely has no equivalent​

Three things, and they are the honest answer to "can we automate this".

Orchestration that branches on computed values. Covered above: persist the value, then condition on it, or write a condition authenticator.

<ValidationTechnicalProfile> chains. B2C lets a self-asserted profile validate its input by running another technical profile, which can itself call REST and fail with a specific user-facing message. Keycloak's equivalent is a form authenticator that does the validation inline and re-renders the form with an error, which means the validation and the form are one class rather than two composable pieces. Each chain is a small amount of Java.

The IEF claims-transformation library itself. There are dozens of transformation methods, and Keycloak does not ship equivalents as a library. Most translate to nothing, because the work moves somewhere better. The ones that do not become code.

There is also no converter, from anyone, in either direction. Microsoft's own migration guide maps custom policies to External ID's custom authentication extensions as a concept, with no tooling. Treat any claim of an automated IEF migration with suspicion.

How long this takes​

Estimate off the table at the top, not off the number of policy files — a tenant with six policy files inheriting from one base is usually less work than a tenant with two that each call three REST endpoints.

Your tenantRough effort
Built-in user flows only, no custom policiesDays. There is nothing to port
Custom policies, no REST profiles, no transformation chainsOne to two weeks of configuration
Custom policies with two or three REST profilesAdd a week per endpoint, including tests
Enterprise federation through custom policiesA few days per IdP, mostly spent on the other side
The lot, plus a bespoke themeTwo to four months end to end

Those are ranges, not quotes. The variance is almost entirely in the REST profiles and in how much undocumented behaviour your policies encode.

Do this next​

Export your policy files and count three things: orchestration steps, technical profiles by protocol, and distinct claims transformations. That takes an hour with grep and it is the only input anyone needs to give you a real estimate.

grep -c '<OrchestrationStep' TrustFrameworkExtensions.xml
grep -o 'Protocol Name="[A-Za-z]*"' TrustFrameworkExtensions.xml | sort | uniq -c
grep -o 'TransformationMethod="[A-Za-z]*"' TrustFrameworkExtensions.xml | sort | uniq -c

Then stand up Keycloak 26.8.1 locally and transcribe your simplest journey — usually sign-up or sign-in — end to end. A day on the simplest one tells you more than a week of planning on the hardest, and it is the only way to find out which of your preconditions test computed values.

The surrounding decisions — whether to move at all, and where — are in what Azure AD B2C's end of sale actually means, and the side-by-side is on Keycloak vs. Azure AD B2C. If you would rather hand the policy files to someone who has read a few of them, talk to us about migrating to Keycloak — and if you want the result run for you afterwards, that is managed Keycloak.