Set Up TOTP Multi-Factor Authentication in Keycloak
Keycloak supports MFA out of the box, but a stock realm does not enforce it. The default browser flow only asks for a one-time password from users who have already enrolled one, so turning on Keycloak MFA is four steps, in this order:
- Set the realm's OTP Policy (Authentication → Policies → OTP Policy).
- Copy the built-in
browserflow — never edit a built-in flow in place. - In the copy, inside the Browser - Conditional 2FA sub-flow, set Condition - user configured to Disabled and OTP Form to Required.
- Bind the copy as the realm's browser flow.
Step 3 is the one that matters and the one the official documentation does not spell out. Without it, every user who has never set up an authenticator app keeps logging in with a password alone — and the admin console gives you no warning that this is happening.
Keycloak 26.7.3 in a container (quay.io/keycloak/keycloak:26.7.3 start-dev). Every
command, every output, every default value and every measured window below is copied from a
real run against that server on 2026-09-14.
What you'll build
A realm where every user — including accounts that existed before you changed anything — is forced to enrol a TOTP authenticator at their next login and to enter a code on every login after that. Plus the verification that proves it, and the four ways this configuration silently fails.
Prerequisites
A running Keycloak and an admin login:
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
If that container is your first one, start with Run Keycloak locally in 5 minutes.
Every CLI step below uses kcadm.sh from inside the container. Define it once:
kcadm() { docker exec -i kc /opt/keycloak/bin/kcadm.sh "$@"; }
kcadm config credentials --server http://localhost:8080 \
--realm master --user admin --password admin
Logging into http://localhost:8080 as user admin of realm master
A realm, a client and a user to test with — see your first realm, client, and user for what each of these is:
kcadm create realms -s realm=prod -s enabled=true
kcadm create clients -r prod -s clientId=demo-app -s publicClient=true \
-s directAccessGrantsEnabled=true \
-s 'redirectUris=["http://localhost:5173/*"]'
kcadm create users -r prod -s username=alice -s enabled=true \
-s email=alice@example.com -s emailVerified=true \
-s firstName=Alice -s lastName=Example
kcadm set-password -r prod --username alice --new-password s3cret
directAccessGrantsEnabled is on so we can test with curl. Section
The direct grant does not get MFA explains why you
must turn it back off afterwards.
Step 0 — prove the default is not MFA
Before changing anything, ask Keycloak for a token with nothing but a password:
curl -s -d client_id=demo-app -d grant_type=password \
-d username=alice -d password=s3cret \
http://localhost:8080/realms/prod/protocol/openid-connect/token
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5…",
"expires_in": 300,
"token_type": "Bearer",
"scope": "profile email"
}
A token, from a password. That is the starting position of every Keycloak realm: MFA is
available, not required. The Browser - Conditional 2FA sub-flow is present in the
default browser flow and it contains Condition - user configured, which means run this
sub-flow only if the user already has a second factor. Nobody has one yet, so nobody is
asked for one.
Step 1 — set the OTP policy
The policy controls what the QR code tells the authenticator app to do. Set it before anyone enrols, because changing it later does not migrate existing credentials (see troubleshooting).
Admin console
Authentication → Policies tab → OTP Policy sub-tab. Set the values, then Save.
kcadm.sh
kcadm update realms/prod \
-s otpPolicyType=totp \
-s otpPolicyAlgorithm=HmacSHA256 \
-s otpPolicyDigits=6 \
-s otpPolicyPeriod=30 \
-s otpPolicyLookAheadWindow=1 \
-s otpPolicyCodeReusable=false
kcadm get realms/prod --fields otpPolicyType,otpPolicyAlgorithm,otpPolicyDigits,otpPolicyPeriod,otpPolicyLookAheadWindow,otpPolicyCodeReusable
{
"otpPolicyType" : "totp",
"otpPolicyAlgorithm" : "HmacSHA256",
"otpPolicyDigits" : 6,
"otpPolicyLookAheadWindow" : 1,
"otpPolicyPeriod" : 30,
"otpPolicyCodeReusable" : false
}
What to set each option to
These are the defaults a fresh 26.7.3 realm ships with, and what we'd actually set them to:
| Console label | API field | Default | Set it to | Why |
|---|---|---|---|---|
| OTP type | otpPolicyType | totp | totp | HOTP's counter drifts, needs a DB write per login, and a stolen code stays valid indefinitely |
| OTP hash algorithm | otpPolicyAlgorithm | HmacSHA1 | HmacSHA256, with the caveat below | Costs nothing server-side, and is only changeable before anyone enrols |
| Number of digits | otpPolicyDigits | 6 | 6 | The console offers only 6 or 8, and the acceptance window below moves the security needle far more than two digits |
| OTP token period | otpPolicyPeriod | 30 | 30 | Anything else breaks apps that assume RFC 6238's default |
| Look around window | otpPolicyLookAheadWindow | 1 | 1 | See the measured table below |
| Reusable token | otpPolicyCodeReusable | false | false | true lets a code phished 20 seconds ago still work |
On HmacSHA256: the server side works. We seeded a SHA-256 credential and authenticated with
a SHA-256 code against 26.7.3, and it was accepted. What we did not test is authenticator
apps — Keycloak puts algorithm=SHA256 in the otpauth:// URI behind the QR code, and an app
that ignores that parameter and computes SHA-1 anyway will generate codes that never validate,
with no error message that says why. Enrol one test user on each authenticator app your users
actually use before you roll SHA-256 out, or stay on the HmacSHA1 default, which every app
implements.
Two naming traps: the console calls it Look around window while the API field is still
otpPolicyLookAheadWindow, and the console's Reusable token is otpPolicyCodeReusable.
Searching for the console label in the Admin REST API docs finds nothing.
The Admin REST API also does no validation on these fields. The console rejects a digit count other than 6 or 8 and a period outside 1 second–2 minutes; the API takes whatever you send:
kcadm update realms/prod -s otpPolicyPeriod=300 -s otpPolicyDigits=7
kcadm get realms/prod --fields otpPolicyPeriod,otpPolicyDigits
{
"otpPolicyDigits" : 7,
"otpPolicyPeriod" : 300
}
No error. You have just generated QR codes that most authenticator apps cannot read. If you configure realms from Terraform or a script, validate these two values yourself.
kcadm update realms/prod -s otpPolicyPeriod=30 -s otpPolicyDigits=6
Step 2 — decide how users enrol
There are two mechanisms and they do genuinely different things. Picking the wrong one is the most common reason an "MFA rollout" leaves half the user base on passwords.
| Required action as default | Flow change (steps 3–4) | |
|---|---|---|
| Where | Authentication → Required actions → Configure OTP → Set as default action | Browser flow's Browser - Conditional 2FA sub-flow |
| Applies to | New users only | Every user, existing accounts included |
| Users already in the realm | Unaffected — they keep logging in with a password | Forced to enrol at next login |
| Users created after you turn it on | Enrolment queued the moment the account is created | Forced to enrol at next login |
| Can a user skip it | No | No |
| Good for | A realm you are creating now | A realm that already has users — i.e. every real rollout |
The required action is worth verifying, because the scope surprises people. Turn it on:
kcadm update authentication/required-actions/CONFIGURE_TOTP -r prod \
-s enabled=true -s defaultAction=true
kcadm create users -r prod -s username=newbie -s enabled=true \
-s firstName=New -s lastName=User -s email=newbie@example.com -s emailVerified=true
kcadm get users -r prod -q username=newbie --fields username,requiredActions
kcadm get users -r prod -q username=alice --fields username,requiredActions
[ {
"username" : "newbie",
"requiredActions" : [ "CONFIGURE_TOTP" ]
} ]
[ {
"username" : "alice",
"requiredActions" : [ ]
} ]
newbie will be asked to enrol. alice, who already existed, will not. If your realm has
users in it, the required action alone is not an MFA rollout — it is an MFA rollout for
people who haven't signed up yet.
Step 3 — require OTP for everyone
Copy the flow first
Keycloak will let you edit the built-in browser flow. Don't. A copy is independently
versionable, can be unbound in one command if a rollout goes wrong, and survives an upgrade
that touches the built-in definitions.
kcadm create authentication/flows/browser/copy -r prod -s newName=browser-mfa
Created new copy with id 'bec4338e-6b41-41a4-a43c-afbd3136db6e'
Copying renames every sub-flow with the new flow's name as a prefix:
Browser - Conditional 2FA becomes browser-mfa Browser - Conditional 2FA. Any script that
matches sub-flows by exact name breaks here.
Change exactly two executions
Inside browser-mfa Browser - Conditional 2FA:
| Execution | Was | Set to | Effect |
|---|---|---|---|
| Condition - user configured | Required | Disabled | Stop skipping the sub-flow for users with no second factor |
| Condition - credential | Required | leave alone | Keeps the sub-flow conditional — see the warning below |
| OTP Form | Alternative | Required | A user with no OTP credential is sent to enrolment instead of past it |
Admin console
- Authentication → Flows → browser-mfa.
- Find the browser-mfa Browser - Conditional 2FA sub-flow, nested under forms.
- On its Condition - user configured row, select Disabled.
- On its OTP Form row, select Required.
There are two rows called Condition - user configured in this flow. The other one
belongs to Browser - Conditional Organization near the top. Changing that one does nothing
for MFA and will break organization-based login. Check which sub-flow the row is indented
under before you touch it.
kcadm.sh
Execution IDs are generated per realm, so look them up rather than hard-coding them. Save
this as require-otp.sh:
#!/usr/bin/env bash
set -euo pipefail
REALM="${1:?usage: require-otp.sh <realm> [flow]}"
FLOW="${2:-browser-mfa}"
kcadm() { docker exec -i kc /opt/keycloak/bin/kcadm.sh "$@"; }
EXECS=$(kcadm get "authentication/flows/$FLOW/executions" -r "$REALM")
# the "Condition - user configured" inside the Conditional 2FA sub-flow,
# not the one inside Conditional Organization
COND_ID=$(jq -r '. as $a
| (to_entries | map(select(.value.displayName | endswith("Browser - Conditional 2FA"))) | .[0].key) as $i
| [$a[$i+1:][] | select(.providerId=="conditional-user-configured")][0].id' <<<"$EXECS")
OTP_ID=$(jq -r 'first(.[] | select(.providerId=="auth-otp-form") | .id)' <<<"$EXECS")
echo "{\"id\":\"$COND_ID\",\"requirement\":\"DISABLED\"}" \
| kcadm update "authentication/flows/$FLOW/executions" -r "$REALM" -n -f -
echo "{\"id\":\"$OTP_ID\",\"requirement\":\"REQUIRED\"}" \
| kcadm update "authentication/flows/$FLOW/executions" -r "$REALM" -n -f -
kcadm get "authentication/flows/$FLOW/executions" -r "$REALM" --fields displayName,requirement \
| jq -r '.[] | select(.displayName | test("Conditional 2FA|user configured|credential|OTP Form|Username Password"))
| " \(.displayName): \(.requirement)"'
bash require-otp.sh prod browser-mfa
Condition - user configured: REQUIRED
Username Password Form: REQUIRED
browser-mfa Browser - Conditional 2FA: CONDITIONAL
Condition - user configured: DISABLED
OTP Form: REQUIRED
Condition - credential: REQUIRED
The first Condition - user configured: REQUIRED is the organization sub-flow's, untouched,
exactly as it should be.
The -n (--no-merge) flag and -f - are both required. kcadm update … -s id=… -s requirement=… fails on this endpoint, because kcadm tries to GET the current state first
and this endpoint returns an array:
HTTP request error: Cannot deserialize value of type
`com.fasterxml.jackson.databind.node.ObjectNode` from Array value (token `JsonToken.START_ARRAY`)
Condition - credentialIt looks like leftover scaffolding. It is not: in 26.x it carries
credentials: webauthn-passwordless, which is what lets Keycloak skip the second factor when
the first factor was a passwordless passkey.
More importantly, a conditional sub-flow with no enabled conditions is skipped entirely,
not executed. Disable both conditions and the sub-flow containing your Required OTP Form
never runs — Keycloak completes the login and redirects the user to the application with an
authorization code. We tested it: the console still shows OTP Form: Required, and a user with
no second factor gets straight in on a password. This is the quietest way to ship an MFA
configuration that does nothing.
Bind the copy
Nothing has changed for users until the realm points at the new flow.
Admin console
Authentication → Flows → the browser-mfa row's kebab menu → Bind flow → Browser flow → Save.
kcadm.sh
kcadm update realms/prod -s browserFlow=browser-mfa
kcadm get realms/prod --fields browserFlow
{
"browserFlow" : "browser-mfa"
}
To roll back, bind browser again. One command, no data loss — which is the reason for the
copy.
Step 4 — verify it worked
Not "log in and you should see a QR code". Drive the login endpoint and check where the server sends you.
AUTH="http://localhost:8080/realms/prod/protocol/openid-connect/auth?client_id=demo-app&response_type=code&scope=openid&redirect_uri=http%3A%2F%2Flocalhost%3A5173%2Fcb&state=xyz"
curl -s -c cj.txt "$AUTH" -o login.html
ACTION=$(grep -o 'action="[^"]*"' login.html | head -1 | sed 's/action="//;s/"$//;s/&/\&/g')
curl -s -b cj.txt -c cj.txt -L -D hdr.txt \
-d "username=alice" -d "password=s3cret" -d "credentialId=" "$ACTION" -o step2.html
grep -i '^location:' hdr.txt | tail -1
grep -o 'name="totp"\|name="userLabel"\|data:image/png' step2.html | sort -u
Location: http://localhost:8080/realms/prod/login-actions/required-action?execution=CONFIGURE_TOTP&client_id=demo-app&tab_id=ADBGjFPI5iY&client_data=…
data:image/png
name="totp"
name="userLabel"
execution=CONFIGURE_TOTP is the proof. alice existed before any of this, has no OTP
credential and no required action on her account, and Keycloak is nonetheless refusing to
complete her login until she enrols. The data:image/png is the QR code and name="totp" is
the field she confirms it with.
Run the same block against a realm where step 3 was skipped and the final Location: is your
application's redirect URI instead. That difference is the whole test, and it is worth keeping
as a smoke check — it is the only way to catch an MFA configuration that has silently stopped
being enforced.
How long a TOTP code is actually valid
"Look around window" is the setting people change when users complain about codes being
rejected, usually without knowing what they are trading away. A window of W accepts the
current time step and W steps either side — 2W + 1 codes, each otpPolicyPeriod seconds
long. Measured on 26.7.3 with a 30-second period, by submitting codes generated at a known
offset from server time:
| Look around window | Time steps accepted | Codes valid at once | Wall-clock validity | Odds a random 6-digit guess works |
|---|---|---|---|---|
| 1 (default) | −1, 0, +1 | 3 | 90 s | 3 in 1,000,000 |
| 2 |