Build a Custom Keycloak Authentication Flow
A Keycloak custom authentication flow is a copy of a built-in flow with your own executions added to it. Four steps, always in this order:
- Copy a built-in flow — built-in flows are read-only, so you cannot edit
browser. - Add a subflow and put your executions inside it. Every execution arrives
DISABLED. - Set the requirement on the subflow and on each execution —
REQUIRED,ALTERNATIVE,CONDITIONAL, orDISABLED. - Bind the copy as the realm's browser flow.
The part that trips people up is step 3. This page builds a working conditional flow — users holding one role must present a TOTP code, everyone else logs in with a password — and then shows the five ways the requirement model fails silently, with the output from each.
Keycloak 26.7.3, start-dev, H2 dev database. Every command, every error message, and
every login result below is copied from an actual run. The
official reference for authentication flows
documents what each requirement means; this page is about what to set and what breaks.
The requirement model in four rules
Keycloak walks a flow top to bottom, one nesting level at a time. At each level:
| Requirement | What Keycloak does |
|---|---|
REQUIRED | Must succeed. If it fails, the whole flow fails. |
ALTERNATIVE | One of the alternatives at this level must succeed — but only if no REQUIRED exists at the same level. |
CONDITIONAL | Subflows only. Evaluate the condition executions inside it; if they all pass, run the rest of the subflow. Otherwise skip it. |
DISABLED | Skipped. |
The second row is the rule that costs people a weekend:
REQUIRED at a level disables every ALTERNATIVE at that levelIt is not an error, there is no warning, and the flow still works — it just stops doing what
you meant. The top level of the browser flow is Cookie (ALTERNATIVE),
Identity Provider Redirector (ALTERNATIVE), and forms (ALTERNATIVE). Flip forms to
REQUIRED and Cookie never runs, which means single sign-on stops working. Measured on
26.7.3, same cookie jar, second app visit:
forms = ALTERNATIVE -> SSO revisit: immediate code (Cookie authenticator ran)
forms = REQUIRED -> SSO revisit: login form again
CONDITIONAL is only offered on subflows. You can check what is legal for any execution
without guessing — the API tells you:
kcadm.sh get 'authentication/flows/browser/executions' -r master \
--fields displayName,requirement,requirementChoices
{
"requirement" : "REQUIRED",
"displayName" : "Username Password Form",
"requirementChoices" : [ "REQUIRED" ]
}, {
"requirement" : "CONDITIONAL",
"displayName" : "Browser - Conditional 2FA",
"requirementChoices" : [ "REQUIRED", "ALTERNATIVE", "DISABLED", "CONDITIONAL" ]
}, {
"requirement" : "REQUIRED",
"displayName" : "Condition - user configured",
"requirementChoices" : [ "REQUIRED", "DISABLED" ]
}
Condition executions accept REQUIRED or DISABLED and nothing else. A condition set to
DISABLED is not "off" in the sense you want — see the failure modes below.
What we are building
Two classes of user in one realm:
- alice — no special role. Password only.
- bob — holds
finance-admin. Must present a TOTP code, and is forced to enrol one if he has not.
Single sign-on keeps working for both.
Step 0 — a realm you can break
docker run -d --name kc -p 127.0.0.1:8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.7.3 start-dev
Everything below runs kcadm.sh inside that container. Two shell helpers do the repetitive
work — the second one matters, and the section after it explains why.
kc() { docker exec -i kc /opt/keycloak/bin/kcadm.sh "$@"; }
kc config credentials --server http://localhost:8080 \
--realm master --user admin --password admin
# set_requirement <url-encoded flow alias> <execution display name> <requirement>
set_requirement() {
kc get "authentication/flows/$1/executions" -r demo \
| jq -c --arg n "$2" --arg r "$3" '.[] | select(.displayName==$n) | .requirement=$r' \
| kc update "authentication/flows/$1/executions" -r demo -f -
}
Realm, client, role, and two users:
kc create realms -s realm=demo -s enabled=true
kc create clients -r demo -s clientId=demo-app -s publicClient=true \
-s 'redirectUris=["http://localhost:9090/*"]' -s standardFlowEnabled=true
kc create roles -r demo -s name=finance-admin
for u in alice bob; do
kc create users -r demo -s username=$u -s enabled=true -s emailVerified=true \
-s firstName=$u -s lastName=Tester -s email=$u@example.com
kc set-password -r demo --username $u --new-password "$u-pw"
done
kc add-roles -r demo --uusername bob --rolename finance-admin
Set firstName, lastName, and email at creation. Leave them out and your first login
stops at execution=VERIFY_PROFILE instead of reaching your flow, which looks exactly like
a broken flow.