Get self-locking sessions in Keycloak with PIN step-up authentication

Get self-locking sessions in Keycloak with PIN step-up authentication

TL;DR

Have you ever wished your Keycloak sessions could lock themselves after a few minutes of inactivity on sensitive features — without logging users out? That’s exactly what this does. A user logs in with their password (good for hours), then enters a short PIN to access sensitive features (good for 5 minutes). When the PIN expires, the session stays alive but the sensitive stuff is locked until they re-enter the PIN.

No custom Java code for the locking mechanism. Just Keycloak’s built-in Level of Authentication (LOA) step-up, combined with the PIN Code Authenticator extension.

Repository: https://github.com/please-openit/keycloak-pin-code-authenticator

References: This is a real life implementation of the concepts described here : https://blog.please-open.it/posts/acr/ If you are not familiar with ACR/LOA and their technical behaviors, this article will help you understand.

Disclaimer and license

The auto-lock / LOA step-up mechanism described in this article relies entirely on built-in Keycloak features (available since Keycloak 23). The PIN Code Authenticator extension itself is production-ready — it includes Argon2 hashing, proper credential storage, admin PIN reset, and has been tested against Keycloak 26.x — but it comes without any warranty. Use it at your own risk.

If you find a bug or have an idea, feel free to open an issue or submit a pull request.

This project is distributed under the Apache 2.0 license. See LICENSE in the git repository.


The everyday frustration you already know

If you’ve spent any time administering Keycloak for a real company, you’ve probably been in this meeting. Someone from security says “sessions should time out after 5 minutes”. Someone from the business side says “our users are complaining they have to log in 40 times a day”. And you’re sitting in the middle, thinking: there has to be a better way.

Here’s the thing — both sides are right. A nurse looking at patient records shouldn’t still have access an hour after walking away from the screen. But a back-office employee reviewing reports shouldn’t need to re-enter their password every time they go grab a coffee.

Traditional approaches force you to pick a side:

ApproachDownside
Short session timeoutUsers re-enter credentials constantly — productivity killer
Long session timeoutSensitive actions stay exposed for hours
Manual “lock screen” buttonUsers forget to click it — security gap
Separate login for sensitive areasClunky UX, users hate it

What if you didn’t have to pick? What if the session could just… lock itself?


The idea: two tiers of trust within a single session

The concept is surprisingly simple once you see it. Instead of one session timeout that applies to everything, you create two levels of trust — each with its own lifetime:

stateDiagram-v2
    [*] --> NotAuthenticated
    NotAuthenticated --> LOA1_Password : Login (password)
    LOA1_Password --> LOA2_PIN : Step-up (enter PIN)
    LOA2_PIN --> LOA1_Password : ⏱ PIN expires (auto)
    LOA1_Password --> LOA2_PIN : Re-enter PIN
    LOA1_Password --> NotAuthenticated : Logout
    LOA2_PIN --> NotAuthenticated : Logout

    state LOA1_Password {
        [*] --> BasicAccess
        BasicAccess : ✅ Dashboard, profile, read-only views
        BasicAccess : ❌ Cannot access sensitive features
    }
    state LOA2_PIN {
        [*] --> FullAccess
        FullAccess : ✅ Everything, including sensitive features
        FullAccess : ⏱ Expires after 5 minutes
    }
LevelWhat the user didWhat they can accessHow long it lasts
LOA 1Entered username + passwordBasic features (dashboard, profile, read-only views)10 hours (full workday)
LOA 2Entered their PINEverything — including sensitive features5 minutes (then auto-expires)

When LOA 2 expires, the session doesn’t end. The user simply drops back to LOA 1 — still logged in, still productive, just locked out of the sensitive stuff until they tap their PIN again. No re-entering the password. No lost work. No frustration.

And the best part? This uses zero custom code for the locking mechanism itself. It’s all built into Keycloak already. You just need a second authentication factor — and that’s where the PIN extension comes in.


Let’s see it in action

Enough theory. Here’s a complete walkthrough using the demo application included in the repository. You’ll see exactly what the user experiences, from first login to auto-lock.

Starting point — not logged in

The user opens the application. Nothing fancy — just a login button.

Demo app — not authenticated

Logging in with a password

Clicking Login redirects to Keycloak’s standard login form. The user enters their password. This gets them to LOA 1 — basic access.

Keycloak login page

First-time PIN setup

On their very first login, the user is asked to choose a PIN. This is a one-time setup step using Keycloak’s Required Action mechanism. The PIN is hashed with Argon2 and stored securely — the user never has to set it up again.

Configure PIN page

Authenticated at LOA 1 — basic access

Back in the app, the user is logged in with basic access. The orange LOA 1 badge shows their current trust level. They can browse the dashboard and view their profile, but the sensitive features? Locked.

Demo app — LOA 1 with password only

Stepping up to LOA 2

When the user clicks Step-up (or tries to access a protected feature), the app redirects to Keycloak. Since the password session is still valid, Keycloak only asks for the PIN — no password re-entry.

PIN step-up prompt

Full access at LOA 2

After entering the correct PIN, the app shows LOA 2 with a green badge and a countdown timer. The user knows exactly how long until the elevated access expires.

Demo app — LOA 2 with timer

Accessing the protected feature

While LOA 2 is active, sensitive features work. The app checks the acr claim in the token to verify that the user’s trust level is high enough.

Protected feature — access granted

The auto-lock kicks in

Five minutes pass. The timer runs out. The session is NOT destroyed. But when the user tries to access the protected feature again, the app detects that LOA 2 has expired and redirects to Keycloak for PIN re-entry.

Session locked — LOA 2 expired

The cycle repeats: enter PIN → 5 minutes of full access → auto-lock → enter PIN again. The user is never logged out, never loses their work, never has to re-enter their password.


What’s happening under the hood

Now let’s look at the Keycloak configuration that makes all of this work. If you’ve ever created a custom authentication flow in Keycloak, you’ll find this very familiar.

The authentication flow

The entire mechanism is a single browser flow with two conditional sub-flows, each gated by Keycloak’s built-in Level of Authentication Condition.

Here’s what it looks like in the Keycloak admin console:

Authentication flow list in admin console

And here’s the flow detail — you can see the two LOA conditions with their respective authenticators:

Authentication flow detail — LOA conditions

In text form, the flow structure is:

browser-with-pin-loa (top-level flow)
│
├── Cookie [ALTERNATIVE]
│   └── Checks for existing SSO session
│
└── Forms [ALTERNATIVE]
    │
    ├── LOA 1 — Password [CONDITIONAL]
    │   ├── Condition: Level of Authentication (level=1, maxAge=36000s)
    │   └── Executor: Username Password Form
    │
    └── LOA 2 — PIN [CONDITIONAL]
        ├── Condition: Level of Authentication (level=2, maxAge=300s)
        └── Executor: PIN Code Authenticator

The magic is in the maxAge values. LOA 1 (password) lasts 36,000 seconds (10 hours). LOA 2 (PIN) lasts just 300 seconds (5 minutes). When a client requests LOA 2 and those 5 minutes have elapsed, Keycloak automatically triggers the PIN form — without touching the password session at all.

The decision tree

Here’s exactly how Keycloak evaluates each authentication request:

flowchart TD
    Request([🔐 Authentication Request]) --> Cookie{SSO Cookie?}
    
    Cookie -->|Valid cookie exists| Done([✅ Authenticated])
    Cookie -->|No cookie| LOA{"What LOA is<br/>requested?"}
    
    LOA -->|LOA 1 or higher| CheckLOA1{LOA 1 still<br/>valid?}
    CheckLOA1 -->|No — expired or first login| PasswordForm[/Username + Password Form/]
    CheckLOA1 -->|Yes — password session still fresh| SkipPassword[Skip password]
    
    PasswordForm --> LOA1Done([LOA 1 achieved ✅])
    SkipPassword --> CheckLOA2
    
    LOA -->|LOA 2 requested| CheckLOA1_2{LOA 1 still<br/>valid?}
    CheckLOA1_2 -->|No| PasswordForm2[/Username + Password Form/]
    CheckLOA1_2 -->|Yes| CheckLOA2
    PasswordForm2 --> CheckLOA2
    
    CheckLOA2{LOA 2 still<br/>valid?}
    CheckLOA2 -->|No — PIN expired| PINForm[/PIN Code Form/]
    CheckLOA2 -->|Yes — PIN still fresh| LOA2Done([LOA 2 achieved ✅])
    PINForm --> LOA2Done
    
    style PasswordForm fill:#4a90d9,stroke:#2c5f8a,color:#fff
    style PasswordForm2 fill:#4a90d9,stroke:#2c5f8a,color:#fff
    style PINForm fill:#e8a838,stroke:#b07c28,color:#fff
    style Done fill:#5cb85c,stroke:#3d8b3d,color:#fff
    style LOA1Done fill:#5cb85c,stroke:#3d8b3d,color:#fff
    style LOA2Done fill:#5cb85c,stroke:#3d8b3d,color:#fff
    style Request fill:#7b68ee,stroke:#5a4fcf,color:#fff

The full sequence of events

If you’re more of a sequence-diagram person, here’s the complete interaction between the user, your application, and Keycloak across all three phases — login, step-up, and auto-lock:

sequenceDiagram
    actor User
    participant App as Your Application
    participant KC as Keycloak

    Note over User,KC: 🔓 Phase 1 — Login (LOA 1)
    User->>App: Opens application
    App->>KC: Authorization request (acr_values=password)
    KC->>User: Username + password form
    User->>KC: Enters credentials
    KC->>App: Tokens with acr=password (LOA 1)
    App->>User: Dashboard (basic access)

    Note over User,KC: 🔐 Phase 2 — Step-up (LOA 2)
    User->>App: Clicks "Approve payment"
    App->>KC: Authorization request (claims: acr=pin, essential)
    Note right of KC: Password session is still valid<br/>→ skip password form
    KC->>User: PIN form only
    User->>KC: Enters PIN
    KC->>App: Tokens with acr=pin (LOA 2)
    App->>User: Payment approved ✅

    Note over User,KC: ⏱️ 5 minutes pass...

    Note over User,KC: 🔒 Phase 3 — Auto-lock
    User->>App: Clicks "Approve another payment"
    App->>KC: Authorization request (claims: acr=pin, essential)
    Note right of KC: PIN has expired (maxAge=300s)<br/>Password still valid<br/>→ show PIN form only
    KC->>User: PIN form (re-entry)
    User->>KC: Enters PIN again
    KC->>App: Fresh tokens with acr=pin (LOA 2)
    App->>User: Payment approved ✅

The user’s credentials in Keycloak

From the admin side, you can see that the user has two credential types stored — their password and their PIN:

User credentials in admin console

The PIN is hashed with Argon2 (just like the password). It can be reset by an admin, which triggers a “Configure PIN” required action on the user’s next login.


Setting this up yourself

If you’ve made it this far and you’re thinking “I want to try this” — good news: the entire setup takes about 15 minutes. Here’s everything you need.

Prerequisites

  • Keycloak 23+ (LOA conditions were introduced in Keycloak 23 — this demo tested on 26.5.3)
  • The PIN Code Authenticator extension JAR in your providers/ directory
  • An OIDC client for your application

Step 1 — Create the authentication flow

In the Keycloak admin console, go to Authentication → Flows and create a new top-level flow. Add executions in this order:

ExecutionTypeRequirement
CookieauthenticatorALTERNATIVE
Forms (sub-flow)ALTERNATIVE
↳ LOA 1 — Password (sub-flow)CONDITIONAL
  ↳ Level of Authentication ConditionconditionREQUIRED
  ↳ Username Password FormauthenticatorREQUIRED
↳ LOA 2 — PIN (sub-flow)CONDITIONAL
  ↳ Level of Authentication ConditionconditionREQUIRED
  ↳ PIN Code AuthenticatorauthenticatorREQUIRED

Configure the LOA conditions:

Sub-flowloa-condition-levelloa-max-age
LOA 1 — Password136000 (10 hours)
LOA 2 — PIN2300 (5 minutes)

Step 2 — Map ACR values to LOA numbers

Keycloak needs to know that the string "password" means LOA 1 and "pin" means LOA 2. Add this attribute on the realm (Realm Settings → Attributes) and on each client (Client → Attributes):

acr.loa.map = {"password":1,"pin":2}

Step 3 — Add the ACR mapper to your clients

For each OIDC client, add a protocol mapper:

SettingValue
Mapper TypeUser Session Note (ACR)
Token Claim Nameacr
Add to ID tokenON
Add to access tokenON

Step 4 — Bind the flow

Go to Authentication → Flows, select your new flow, and bind it as the Browser flow.

Step 5 — Tune the timeout

The loa-max-age on LOA 2 is the knob you’ll want to turn. Here are some starting points:

loa-max-ageDurationBest for
601 minuteHigh-security: banking, healthcare
3005 minutesBalanced default: general enterprise apps
90015 minutesConvenience: internal tools
36001 hourLow-friction: infrequent sensitive actions
0Every requestMaximum security: PIN on every single step-up

Integrating it in your application

Your application triggers step-up authentication using standard OIDC parameters. No proprietary APIs, no Keycloak-specific SDKs. Any OIDC-compliant library will work.

Requesting elevated access

When a user tries to access a sensitive feature, redirect them to Keycloak’s authorization endpoint with the claims parameter:

// "I need ACR=pin, and it's mandatory"
const claims = JSON.stringify({
  id_token: {
    acr: { essential: true, values: ['pin'] }
  }
});

const authUrl = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/auth`
  + `?client_id=${clientId}`
  + `&redirect_uri=${encodeURIComponent(redirectUri)}`
  + `&response_type=code`
  + `&scope=openid`
  + `&claims=${encodeURIComponent(claims)}`
  + `&code_challenge=${challenge}`
  + `&code_challenge_method=S256`;

window.location.href = authUrl;

Why essential: true? Without it, Keycloak treats the ACR request as voluntary and might return a lower level. With essential: true, Keycloak must satisfy the request — if the PIN has expired, it will prompt for re-entry.

Checking the trust level after redirect

Read the acr claim from the ID token to know what the user proved:

const payload = JSON.parse(atob(idToken.split('.')[1]));
const acrMap = { password: 1, pin: 2 };
const currentLoa = acrMap[payload.acr] || 0;

if (currentLoa >= 2) {
  showProtectedContent();  // ✅ Elevated access — go ahead
} else {
  redirectToStepUp();      // ⚠️ PIN expired — request step-up
}

Optional: client-side countdown timer

The demo app includes a countdown timer showing when LOA 2 will expire. This is purely a UX convenience — the real enforcement is always server-side.

const issuedAt = payload.iat * 1000;  // seconds → ms
const maxAge = 300 * 1000;            // 5 minutes
const expiresAt = issuedAt + maxAge;

const timer = setInterval(() => {
  const remaining = Math.max(0, expiresAt - Date.now());
  if (remaining === 0) {
    clearInterval(timer);
    showLockedState();
  } else {
    updateCountdown(remaining);
  }
}, 1000);

Beyond PIN: other use cases for LOA step-up

Have you noticed that nothing in the LOA mechanism is specific to PINs? You could swap the PIN authenticator with any second factor — OTP, WebAuthn, smart card — and the auto-lock behavior would work exactly the same way.

Here are some real-world scenarios where this pattern shines:

Healthcare — Patient record access

  • LOA 1 (password): View appointment schedule, send messages, update personal info
  • LOA 2 (PIN, 5 min): Access patient diagnoses, prescribe medication, view lab results
  • Clinicians stay logged in during shifts but must re-PIN before each patient interaction

Finance — Transaction approval

  • LOA 1 (password): View account balances, generate reports, browse history
  • LOA 2 (PIN, 2 min): Approve wire transfers, modify beneficiary lists, change limits
  • Back-office staff review data freely; approvals require fresh proof of identity

Enterprise — Admin consoles

  • LOA 1 (password): View dashboards, read documentation, update profile
  • LOA 2 (PIN, 15 min): Manage users, change configurations, access audit logs
  • IT admins don’t re-login all day, but admin actions are gated

Shared workstations

  • LOA 1 (password): General use of the shared terminal
  • LOA 2 (PIN, 1 min): Any action tied to the individual user’s identity
  • User walks away → PIN expires in 60 seconds → data is safe

You could even combine multiple step-up levels — LOA 1 for password, LOA 2 for OTP, LOA 3 for PIN — with increasingly shorter timeouts. Keycloak’s conditional flow mechanism supports this natively.


Limitations and things you should know

This isn’t a magic bullet. Here are the important caveats:

LOA doesn’t downgrade in real-time

Keycloak does not actively revoke LOA 2 when the clock runs out. The expiry is evaluated on the next authentication request — meaning when your application redirects the user to Keycloak. Between those requests, previously issued tokens remain valid until their own exp claim.

If you’re using JWT-based authentication (e.g., stateless API calls), make sure your access token lifespan is at most equal to the LOA max age. Otherwise, a token issued during LOA 2 could still be accepted after the PIN has “expired.”

The client-side timer is a UX signal, not enforcement

The countdown timer in the demo app is there to give users a visual cue. The actual enforcement happens server-side when the client redirects to Keycloak. A user who stays on the same page and never triggers a new auth request will keep their current LOA until the token expires.

This is not a screen lock

This mechanism doesn’t detect idle time or lock the screen. It’s a protocol-level session tier. If you need a screen lock, you’ll want to combine this with a client-side inactivity timer that triggers the step-up redirect.

The PIN extension is production-ready, without warranty

Unlike many proof-of-concept Keycloak extensions you’ll find on GitHub, this one is designed for real use:

  • Argon2 hashing with transparent algorithm migration for future or custom hash integration.
  • Admin PIN reset via email with required action
  • Configurable formats (numeric, alphanumeric, custom patterns)
  • Server-side visual keyboard with OCR obfuscation for kiosk-style input
  • Fully unit-tested and E2E tested

That said, it comes with no warranty. Review the code, test it in your environment, and decide for yourself. The Apache 2.0 license gives you full freedom to modify and redistribute.


Try it yourself

The fastest way to understand this feature is to experience it. The entire demo runs locally with Docker — no cloud account needed.

Quick start

# Clone and build
git clone https://github.com/please-openit/keycloak-pin-code-authenticator.git
cd pin-code-authenticator
mvn clean package -DskipTests

# Start Keycloak + demo app (wipe volumes for clean realm import)
docker compose down -v && docker compose up -d

# Wait for Keycloak to be ready (~30s)
until curl -sf http://localhost:8080/health/ready > /dev/null; do sleep 5; done

echo "Open http://localhost:3000 in your browser"

Test credentials: testuser / password123

Walk through the demo manually — login, set up your PIN, step up, access the protected feature, wait for the timer, try again. It takes about 2 minutes and it really clicks once you see it.

Automated test (if you’re in a hurry)

A headed Puppeteer test runs the entire scenario in a visible Chrome window:

# Fast mode — 30-second PIN timeout instead of 5 minutes
node e2e/test-session-lock.js --fast

# Standard mode — real 5-minute timeout
node e2e/test-session-lock.js

The test walks through all 10 steps — from login to auto-lock to re-authentication — and keeps the browser open for visual inspection.


Conclusion

If you’ve ever struggled with the session-timeout-versus-usability dilemma, LOA step-up is the answer you didn’t know Keycloak already had. It’s been there since version 23, it requires zero custom code for the locking mechanism, and combined with a simple second factor like a PIN, it gives you auto-locking sessions that don’t annoy your users.

The PIN Code Authenticator extension gives you that second factor — Argon2-hashed, admin-resettable, format-configurable, and ready to drop into your providers/ directory.

Give it a try. Break it. Open an issue. Submit a PR. That’s how good software gets built.

Repository: [https://github.com/please-openit/keycloak-pin-code-authenticator](https://github.com/please-openit/keycloak-pin-code-authenticator