Step-up authentication means the user signs in with something cheap (a password, a passkey on a managed device) and is only asked for more when they do something that deserves it: change a payout account, approve a wire, export customer data. Keycloak supports this natively through ACR (Authentication Context Class Reference) values mapped to numeric Levels of Authentication (LoA), and the whole thing is driven from the login flow rather than application code.

This guide covers the Keycloak-specific wiring: the realm mapping, the conditional browser flow, what the application sends, what actually ends up in the token, and two places where step-up quietly fails to protect you. For the vendor-neutral design patterns (risk signals, which operations qualify) read Implementing Step-Up Authentication for Sensitive Operations first; this article is the Keycloak implementation companion.

How Keycloak models step-up

Three pieces work together:

  1. ACR to LoA mapping. The ACR can be any string you like (silver, gold, 2), but the LoA must be a number. The mapping lives in the realm’s general settings, and you can override it per client if one client needs different values. Keycloak’s own documentation recommends sticking with the realm-level mapping where you can.
  2. A browser flow with LoA conditions. Each authentication level is a conditional subflow guarded by the Condition - Level of Authentication authenticator. The condition has two settings: the Level of Authentication it represents and a Max Age in seconds.
  3. The request. The client asks for a level with acr_values or the OIDC claims parameter. The granted level comes back as the acr claim in the ID token and access token.

One design limit worth knowing before you start: Keycloak’s levels are hierarchical. Level 2 stacks on top of everything level 1 required. If your organisation accepts several unrelated methods as equivalent (for example “hardware key or push approval, either is fine”), a single numeric ladder models that awkwardly, and Red Hat’s own tracker notes the implementation only suits hierarchical method sets.

Step 1: Map ACR values to levels

In the Admin Console open Realm settings > General and add the mapping table entries:

ACRLoAMeaning in this guide
11Password (or passkey) only
22Password plus OTP or WebAuthn second factor

Using the numbers themselves as ACR strings keeps requests readable (acr_values=2), but named values such as mfa are easier to reason about in audit logs. Pick one convention and keep it across clients.

Step 2: Build the step-up browser flow

Duplicate the built-in browser flow (Authentication > Flows > browser > Duplicate) and name it step-up-browser. The shape you want:

step-up-browser
├── Cookie                                   ALTERNATIVE
├── Identity Provider Redirector             ALTERNATIVE
└── step-up-forms                            ALTERNATIVE
    ├── Level 1 (conditional subflow)        CONDITIONAL
    │   ├── Condition - Level of Authentication   REQUIRED   (Level of Authentication = 1)
    │   └── Username Password Form                REQUIRED
    └── Level 2 (conditional subflow)        CONDITIONAL
        ├── Condition - Level of Authentication   REQUIRED   (Level of Authentication = 2, Max Age = 300)
        └── OTP Form                              REQUIRED

Open the gear icon next to each Condition - Level of Authentication execution and set:

  • Level of Authentication: 1 for the first subflow, 2 for the second.
  • Max Age: how many seconds a completed authentication at that level stays “fresh”. For a level 2 prompt that guards a payment screen, a few minutes is typical.

Then bind the flow: Action > Bind flow > Browser flow. If you only want the new flow for one application, use a client-level authentication flow override instead, so the rest of the realm keeps its current login behaviour. If you want a third tier (say WebAuthn at level 3), add another conditional subflow with the same pattern. For flow-building fundamentals, including custom authenticators, see Keycloak Custom Authentication Flows.

Step 3: Ask for the level from the application

The normal login requests nothing special. When the user clicks the sensitive action, redirect them to the authorization endpoint again with a higher level:

GET /realms/my-realm/protocol/openid-connect/auth
  ?client_id=my-app
  &redirect_uri=https://app.example.com/callback
  &response_type=code
  &scope=openid
  &acr_values=2

With an existing SSO session at level 1, Keycloak skips the password form, hits the level 2 subflow, prompts for OTP, and returns a code that exchanges for tokens carrying "acr": "2" and a fresh auth_time.

acr_values versus essential

acr_values is a preference. For a hard requirement use the claims parameter with essential: true:

{ "id_token": { "acr": { "essential": true, "values": ["2"] } } }

URL-encode that JSON into &claims=.... With essential, the authorization server must return one of the listed values or treat the attempt as failed, rather than quietly falling back to a lower level. Use acr_values for “nice to have stronger”, and essential for anything that moves money or changes access.

Never enforce in the front end

A check like if (token.acr === '2') in browser JavaScript is a UX hint, not a control. The API behind the sensitive action must verify the claim itself. A minimal Node.js middleware:

// Express middleware: require a recent level-2 authentication
function requireAcr(minLevel, maxAgeSeconds) {
  return (req, res, next) => {
    const { acr, auth_time } = req.auth; // already signature- and audience-verified
    const age = Math.floor(Date.now() / 1000) - Number(auth_time);
    if (Number(acr) < minLevel || age > maxAgeSeconds) {
      res.set('WWW-Authenticate',
        'Bearer error="insufficient_user_authentication", acr_values="' + minLevel + '"');
      return res.status(401).json({ error: 'step_up_required' });
    }
    next();
  };
}

app.post('/payouts/change-account', requireAcr(2, 300), changePayoutAccount);

The insufficient_user_authentication error and acr_values hint in WWW-Authenticate come from RFC 9470 (OAuth 2.0 Step Up Authentication Challenge Protocol); it lets a well-behaved client know it should restart the authorization request at the higher level. Decode a sample token in the JWT Decoder to confirm acr and auth_time are present before you rely on them.

Pitfall 1: max age does not shorten tokens you already issued

It is natural to read “Max Age = 300” as “the elevated state expires after five minutes”. What it actually governs is whether Keycloak will issue new tokens at that level without prompting again. A level-2 access token minted at minute 1 remains usable for its whole lifespan, and its acr claim stays 2. A community answer on the Keycloak forum describes this directly: the validity of the LoA is just for issuing new tokens.

Practical consequences:

  • Keep the access token lifespan short for clients that use step-up (a few minutes).
  • Have the resource server compare auth_time against its own freshness window, as in the middleware above.
  • Do not treat a long-lived refresh token as proof of recent step-up; refreshing does not re-run the flow.

Red Hat tracks a weakness in this area as CVE-2026-97176 (Bugzilla 2539964, “Essential acr requirement silently bypassed via Cookie authenticator”). As described in the report: when a client requests an acr level as essential and the user already has an SSO session, the browser flow re-evaluates the session through the Cookie authenticator. If the LoA-gated conditional subflow ends up disabled for that user, for instance because they have not enrolled the credential the higher level needs, the callback that performs the forced-level check is never registered. Keycloak can then issue a token with a lower acr than the one demanded, and a relying party that trusts the acr claim sees the weaker session.

At the time of writing the report is rated Medium, its status is NEW, and no fixed version is listed; affected versions are not stated. Treat all of the following as defensive habits rather than a patch:

  1. Verify acr in the resource server, not just in the application’s login code. Compare the claim to the level the operation requires and reject mismatches (the middleware above does).
  2. Enrol users before you require the level. The failure mode depends on a user lacking the credential for the gated subflow. Add a required action that forces OTP or WebAuthn registration, so “has no second factor” is not a reachable state for accounts that can reach sensitive actions.
  3. Check auth_time as well as acr. It gives you an independent freshness signal.
  4. Watch the advisory. Check the Red Hat tracker and the Keycloak release notes for a fix, and test your flow after each upgrade with a user who has no second factor.

This is the same class of problem as MFA bypass attacks: the second factor exists, but a path around it does too.

Testing the flow end to end

  1. Sign in with a test user who has OTP enrolled, requesting no acr_values. Decode the ID token: expect acr of 1 (or the mapped value).
  2. Repeat the authorization request with acr_values=2. Expect an OTP prompt, not a password prompt, because the session is already at level 1.
  3. Wait past Max Age and request acr_values=2 again. Expect another OTP prompt.
  4. Request the claims parameter with essential: true for a user with no OTP enrolled. Expect failure or a forced enrolment, and never a token whose acr is below what you asked for. If you get that token, you have reproduced the gap above in your own realm.
  5. Call the protected API with the level-1 token. Expect 401 with step_up_required.

When Keycloak step-up is not enough

  • If you need risk-based triggers (new device, impossible travel) rather than operation-based ones, step-up flows need a custom authenticator or an external risk engine feeding Keycloak.
  • If the second factor must be phishing-resistant, make level 2 WebAuthn or passkeys rather than OTP; codes can still be relayed by an adversary-in-the-middle proxy.
  • If AI agents act on behalf of users, an agent holding a level-2 token has the user’s elevated state for the token’s lifetime; see AI Agent Impersonation for why sensitive actions should require a fresh, user-present challenge rather than inherited privilege.

Summary

Keycloak step-up comes down to a realm ACR-to-LoA mapping, conditional subflows guarded by Condition - Level of Authentication, and clients that request levels with acr_values or an essential claim. The three things that make it real protection are short token lifespans, enrolment before enforcement, and resource servers that check acr and auth_time themselves.