Benchmarks

FerrisKey 0.10.1 and Keycloak 26.8.1 were run through the same load scale on the same Kubernetes cluster, with the same resources, the same PostgreSQL setup and the same 1,000 users, on six endpoints: client_credentials, password, refresh, introspect, userinfo and jwks. Measured by Nathaël Bonnal; the full report with every step is in IAM-performance-tester.

Build measured

FerrisKey is built from main at ace8c1a, the same code as the v0.10.1 tag (the two differ only in documentation). It runs with REFRESH_TOKEN_FORMAT=opaque and REFRESH_TOKEN_COMMIT=async, two settings introduced in 0.10.1 that are off by default; see refresh for what they change.

Summary

  • FerrisKey sustains the same highest step as Keycloak on all six endpoints.
  • It uses less CPU than Keycloak almost everywhere, often half as much at the same rate. The one exception is userinfo from 3,200 req/s up.
  • On refresh, at 800 req/s, FerrisKey’s p95 is 5.7 ms against 14.5 ms, with 40 % less CPU.
  • Keycloak keeps a lower tail latency (p99) on client_credentials from 800 req/s, on password, and on introspect and userinfo from 3,200 req/s.
  • The gap is widest on the machine-to-machine endpoints (client_credentials, refresh, introspect, jwks); the human password login is where Keycloak keeps the lower tail.
  • FerrisKey uses less memory: 618 MiB at most, against 823 MiB for Keycloak.

Sustained rate, latency and CPU

The sustained rate is the highest step with under 1 % errors, at least 95 % of the target rate served, and a p95 under 250 ms. Latency and CPU are read at 800 req/s, except for password (80 req/s), where Argon2id hashing makes each request expensive by design. CPU is that of the IAM server alone, in cores, averaged over the measured minute.

EndpointFerrisKey sustainedKeycloak sustainedFerrisKey p95Keycloak p95FerrisKey CPUKeycloak CPU
client_credentials3,200 req/s3,200 req/s2.7 ms2.0 ms0.66 cores0.94 cores
password (80 req/s)160 req/s (highest step tested)160 req/s29.2 ms20.9 ms1.26 cores1.46 cores
refresh1,600 req/s (highest step tested)1,600 req/s5.7 ms14.5 ms1.47 cores2.47 cores
introspect6,400 req/s6,400 req/s1.6 ms2.7 ms0.28 cores0.57 cores
userinfo6,400 req/s6,400 req/s1.7 ms2.6 ms0.21 cores0.51 cores
jwks6,400 req/s6,400 req/s1.1 ms1.9 ms0.15 cores0.40 cores

Memory

Pod working set across all steps. FerrisKey starts low and grows as the scenarios run; Keycloak never goes below 530 MiB.

SystemLowestHighest
FerrisKey62 MiB618 MiB
Keycloak533 MiB823 MiB

Endpoint by endpoint

Each table gives latency (p50 / p95 / p99) and IAM server CPU at every step. “Not tested” means that system’s ladder stopped lower.

client_credentials

A client authenticates with its secret and gets an access token. FerrisKey has a lower median and uses less CPU at every step. Its tail is longer from 800 req/s, though: p99 of 65.6 ms at 800 req/s and 25.6 ms at 1,600 req/s, against under 7 ms for Keycloak. At 3,200 req/s FerrisKey still serves 3,181 req/s, but with 0.56 % errors and a p95 of 54 ms, while Keycloak stays at 10 ms.

Target (req/s)FerrisKey p50 / p95 / p99Keycloak p50 / p95 / p99FerrisKey CPU (cores)Keycloak CPU (cores)
502.4 / 5.4 / 7.2 ms5.0 / 8.0 / 9.1 ms0.110.29
1001.4 / 3.9 / 10.3 ms3.4 / 6.7 / 7.5 ms0.170.34
2001.2 / 2.8 / 3.6 ms2.5 / 3.6 / 5.5 ms0.250.29
4001.1 / 2.2 / 3.3 ms1.7 / 2.6 / 3.9 ms0.410.60
8001.1 / 2.7 / 65.6 ms1.4 / 2.0 / 3.5 ms0.660.94
1,6001.1 / 4.0 / 25.6 ms1.5 / 3.7 / 6.7 ms1.512.02
3,2002.3 / 53.6 / 92.6 ms, 0.56 % errors3.2 / 10.5 / 22.5 ms2.643.63

password

A user signs in with a password. Both systems hash with Argon2id (m=7168, t=5, p=1), which puts latency around 20 ms. Keycloak has a shorter tail: at 160 req/s its p95 is 32 ms against 87 ms. Keycloak saturates around 155 req/s. FerrisKey was not tested above 160 req/s.

Target (req/s)FerrisKey p50 / p95 / p99Keycloak p50 / p95 / p99FerrisKey CPU (cores)Keycloak CPU (cores)
1020.6 / 28.5 / 47.3 ms27.6 / 32.6 / 36.5 ms0.160.30
2019.7 / 24.1 / 30.4 ms18.6 / 21.5 / 23.6 ms0.260.31
4020.0 / 33.7 / 70.6 ms18.0 / 20.1 / 22.7 ms0.640.72
8022.2 / 29.2 / 46.0 ms18.4 / 20.9 / 26.7 ms1.261.46
16030.9 / 87.4 / 170 ms21.5 / 32.3 / 40.9 ms3.143.29
320not tested2018 / 2216 / 2266 ms, 152 req/s servedn/a3.52
640not tested1992 / 2077 / 2124 ms, 155 req/s servedn/a3.40

refresh

A client trades its refresh token for a new set of tokens. On both sides the old refresh token is invalidated on every exchange (rotation is enabled on Keycloak). FerrisKey has a lower median at every step and uses about 40 % less CPU. Its p95 is better at 800 and 1,600 req/s. At 400 req/s an isolated spike takes its p99 to 206 ms, while the neighbouring steps stay under 13 ms. Keycloak tops out at 1,766 req/s. FerrisKey was not tested above 1,600 req/s.

FerrisKey ran with REFRESH_TOKEN_FORMAT=opaque (the refresh token is a random string whose hash is stored, instead of a signed JWT) and REFRESH_TOKEN_COMMIT=async (rotations are written with synchronous_commit=off). Both default to off. With async, a database crash can lose the last rotations: each token consumed by a lost rotation works once more and its successor stops working. Revocations stay durable either way.

Target (req/s)FerrisKey p50 / p95 / p99Keycloak p50 / p95 / p99FerrisKey CPU (cores)Keycloak CPU (cores)
504.2 / 9.2 / 18.4 ms5.3 / 7.8 / 12.5 ms0.170.37
1003.4 / 5.6 / 7.6 ms4.3 / 6.0 / 11.5 ms0.250.43
2002.5 / 4.4 / 7.5 ms4.0 / 6.2 / 13.4 ms0.430.74
4002.8 / 38.1 / 206 ms3.8 / 7.7 / 17.9 ms0.901.35
8002.6 / 5.7 / 12.1 ms4.0 / 14.5 / 34.0 ms1.472.47
1,6006.4 / 93.2 / 187 ms15.3 / 139 / 216 ms2.974.63
3,200not tested162 / 232 / 274 ms, 1,766 req/s servedn/a5.27

introspect

A service asks whether an access token is still valid. From 200 to 3,200 req/s both answer in under a millisecond at the median. FerrisKey uses less CPU at every step, up to three times less. From 3,200 req/s up, Keycloak has a lower p95 and p99.

Target (req/s)FerrisKey p50 / p95 / p99Keycloak p50 / p95 / p99FerrisKey CPU (cores)Keycloak CPU (cores)
501.6 / 4.5 / 7.1 ms3.6 / 6.1 / 11.8 ms0.060.18
1001.2 / 3.4 / 5.6 ms2.9 / 4.8 / 6.4 ms0.090.25
2000.9 / 2.4 / 3.6 ms1.5 / 3.3 / 4.3 ms0.130.25
4000.7 / 1.9 / 3.4 ms0.8 / 2.9 / 3.8 ms0.190.37
8000.6 / 1.6 / 2.7 ms0.7 / 2.7 / 4.5 ms0.280.57
1,6000.7 / 1.9 / 4.8 ms0.7 / 2.4 / 5.2 ms0.450.63
3,2000.7 / 2.6 / 8.4 ms0.5 / 1.4 / 4.1 ms0.591.03
6,4001.1 / 6.0 / 19.8 ms0.7 / 3.5 / 7.8 ms1.331.55

userinfo

A client reads the user’s profile with its access token. Up to 1,600 req/s FerrisKey has a lower median and uses less CPU. Above that Keycloak takes the lead: at 6,400 req/s its p99 is 3.7 ms against 12.1 ms, and it uses 1.29 cores against 1.55.

Target (req/s)FerrisKey p50 / p95 / p99Keycloak p50 / p95 / p99FerrisKey CPU (cores)Keycloak CPU (cores)
501.9 / 5.6 / 9.4 ms3.3 / 5.4 / 6.7 ms0.080.16
1001.3 / 3.8 / 5.6 ms2.8 / 4.5 / 5.6 ms0.090.24
2000.8 / 2.6 / 6.0 ms1.6 / 3.2 / 3.9 ms0.120.29
4000.5 / 1.8 / 3.3 ms0.7 / 2.7 / 3.6 ms0.130.33
8000.5 / 1.7 / 3.1 ms0.6 / 2.6 / 3.8 ms0.210.51
1,6000.6 / 1.7 / 3.4 ms0.6 / 2.8 / 4.7 ms0.420.74
3,2000.8 / 2.3 / 4.6 ms0.5 / 1.2 / 2.6 ms0.880.75
6,4001.0 / 4.3 / 12.1 ms0.6 / 1.7 / 3.7 ms1.551.29

jwks

A client fetches the realm’s public keys to check signatures. This is the lightest endpoint. FerrisKey uses 1.7 to 6 times less CPU than Keycloak. p99 stays under 5.2 ms on both sides.

Target (req/s)FerrisKey p50 / p95 / p99Keycloak p50 / p95 / p99FerrisKey CPU (cores)Keycloak CPU (cores)
1000.4 / 1.2 / 2.0 ms2.2 / 3.9 / 5.1 ms0.030.19
2000.4 / 1.1 / 1.8 ms1.5 / 2.9 / 4.0 ms0.050.25
4000.3 / 1.1 / 1.9 ms1.0 / 2.3 / 3.2 ms0.090.33
8000.3 / 1.1 / 2.1 ms0.6 / 1.9 / 3.0 ms0.150.40
1,6000.4 / 1.3 / 2.5 ms0.5 / 2.2 / 3.8 ms0.200.56
3,2000.4 / 1.5 / 3.2 ms0.4 / 1.2 / 3.1 ms0.300.64
6,4000.6 / 2.0 / 3.4 ms0.5 / 1.4 / 3.4 ms0.591.00

Test conditions

  • Cluster: k3d on OrbStack on a Mac, with 8 vCPUs shared by the load generator, the IAM server and PostgreSQL. Each role has its own k3d node, but the physical cores are shared.
  • IAM server: 1 replica, 3 CPUs requested, no CPU limit, 1,536 MiB of memory. Keycloak runs in production mode (start) with -XX:ActiveProcessorCount=3.
  • Database: PostgreSQL 17 (CloudNativePG), one per system, 1 CPU requested, 3 GiB, max_connections 400, on its own node.
  • Load: k6 at constant arrival rate (open model), 1,000 users, 15 s of warmup then 60 s measured per step.
  • FerrisKey: image built from main at ace8c1a, with REFRESH_TOKEN_FORMAT=opaque and REFRESH_TOKEN_COMMIT=async. Measured on 2026-10-31 with 300 pre-allocated k6 VUs.
  • Keycloak: image quay.io/keycloak/keycloak:26.8.1, realm with revokeRefreshToken=true and refreshTokenMaxReuse=0. Refresh measured on 2026-10-31 with 300 pre-allocated VUs. The other five endpoints were measured on 2026-10-26, with k6’s default number of pre-allocated VUs.

What this bench does not tell you

  • Shared machine: load generator, server and database compete for 8 vCPUs. Absolute figures do not transfer to a production cluster; the gaps between the two systems are the reliable part.
  • No CPU limit: both servers request 3 CPUs without a limit, but Keycloak caps itself at 3 cores through -XX:ActiveProcessorCount=3 while FerrisKey can use more. FerrisKey used less CPU anyway at every compared step; its highest reading is 3.14 cores, on password at 160 req/s.
  • Single run: each step was measured once. Differences under 1 ms in p50, or under 20 % in p99, are within the probable noise. The 206 ms p99 spike on refresh at 400 req/s is one such reading.
  • Two dates for Keycloak: five of its endpoints come from the 2026-10-26 run with k6’s default VU allocation; FerrisKey and the Keycloak refresh were measured on 2026-10-31 with 300 pre-allocated VUs.
  • Non-default refresh settings on the FerrisKey side, described under refresh.
  • Ladders stop early: FerrisKey was not pushed past 160 req/s on password or 1,600 req/s on refresh; the table says where each system’s limit is known and where it is not.
  • Raw data: the per-step JSON of the runs stays with the author outside the repository; the report names the three runs it is built from.

Reproduce

The tooling is in k8s/ of IAM-performance-tester, with every variable documented in its README. One system runs at a time; bench.sh scales the other to zero before each phase.

cd k8s
./bench.sh platform     # once per cluster: node roles, CNPG, kube-prometheus-stack
./bench.sh deploy       # Postgres x2, FerrisKey, Keycloak
./bench.sh seed         # realm perf, client perf-client, 1000 users, in both systems
./bench.sh run          # every scenario, both systems, about 1h30 worst case

Before 0.10.1

The same bench run on 2026-10-26 against a build from before the 0.10 token-path work (sha-d73af3e) showed Keycloak holding twice the load on refresh (1,600 against 800 req/s) and on userinfo (6,400 against 3,200 req/s), because FerrisKey queried PostgreSQL on every one of those requests. The refresh grant was reworked in #1875, userinfo reuses the authenticated user since #1863, and sessions and token state are cached between requests since #1870. All three shipped in 0.10.1, and the tables above are the result.