Post

How expensive is a token exchange, really?

A load-test of RFC 8693 token exchange against a two-engine PingFederate cluster on Kubernetes

How expensive is a token exchange, really?

Context

Token exchange is gaining popularity as a way to cross trust boundaries in agentic architectures, and I have described the pattern in earlier posts on SPIFFE workload identity and on protecting MCP servers.

Whenever I propose it, the same performance concern comes up: every extra exchange adds a hop to the authorization server, and a chatty agent could multiply that hop many times. The concern is reasonable, so I measured the token exchange performance against a two-engine PingFederate cluster on Kubernetes.

The result: at 500 exchanges per second the p95 latency was 9.5 milliseconds, with zero failures across the whole campaign. CPU consumption scaled approximately linearly with the exchange rate across the tested range, corresponding to about 5 CPU-ms of aggregate PingFederate engine CPU per completed exchange.

The test stops at 500 exchanges per second by choice. The goal was to establish a representative baseline rather than determine the cluster’s saturation point. The tests were also run in a shared environment without dedicated infrastructure.

The scenario

The deployment is a standard Ping Identity Kubernetes topology: one administration Pod and two PingFederate engine Pods behind a single Kubernetes Service. The test assumes an agentic workload where the agents and PingFederate run within the same Kubernetes environment. The measured path therefore reflects the expected production service-to-service path and intentionally excludes external ingress and user-to-cluster network latency.

Accordingly, load is generated by ten k6 agent Pods running inside the same cluster. Everything runs in an Amazon EKS cluster in eu-west-1: six worker nodes, all r5.xlarge instances (4 vCPU and 32 GiB each), running Amazon Linux 2023.

%%{init: {'flowchart': {'curve': 'linear'}}}%%
flowchart TB
    subgraph CL["Amazon EKS cluster — eu-west-1 (6 nodes, r5.xlarge)"]
        direction TB

        subgraph LB["Load generation"]
            K1["k6 agent-0"]
            K2["k6 agent-1"]
            KM["…"]
            K9["k6 agent-9"]
            JB["Kubernetes Job<br/>10 indexed completions"]
        end

        subgraph PF["PingFederate — namespace pf-perf"]
            direction TB
            ADM["pf-admin<br/>1 Pod, 2 CPU / 4 GiB limit"]
            ENG1["pf-engine-0<br/>2 CPU req / 4 CPU lim"]
            ENG2["pf-engine-1<br/>2 CPU req / 4 CPU lim"]
            SVC["Kubernetes Service<br/>port 9031"]
        end

        JWKS["perf-jwks-server Pod<br/>nginx, serves subject-key JWKS over TLS"]
    end

    JB --> K1 & K2 & KM & K9
    K1 & K2 & KM & K9 -->|"token exchange<br/>POST /as/token.oauth2"| SVC
    SVC --> ENG1 & ENG2
    ENG1 -.->|"JWKS fetch"| JWKS
    ENG2 -.->|"JWKS fetch"| JWKS
    ADM -.->|"config replication"| ENG1
    ADM -.->|"config replication"| ENG2
  • k6 agent Pods: the load generators generate a new JWT subject token at every iteration and exchange it exactly once — no token is reused, no two requests carry the same token. The 10 Pods simulate 10 independent agent instances acting on behalf of one pool of 100 synthetic user identities, rotating round-robin. In PingFederate each Pod authenticates as its own OAuth client — perf-agent-0 through perf-agent-9 — and the subject token’s audience is the calling agent’s client id.
  • Kubernetes Service: load-balances across both engine Pods on port 9031; admin traffic is not part of the test.
  • PingFederate engines: run the measured path — JWKS fetch and caching, JWT validation, token-exchange policy, output-token signing.
  • PingFederate admin: configures the engines through cluster replication and stays idle.
  • perf-jwks-server: a small nginx Pod serving the subject-token verification JWKS over HTTPS (self-signed cert imported into the engines’ JVM truststore); the engines fetch and cache it.

Each exchange is a RFC 8693 request where the clients authenticate with client_secret_basic (no actor token) and exchange a self-contained JWT subject token. The engines authenticate the client, validate the subject token’s signature (using the keys obtained from the JWKS pod), the issuer, the audience, and the expiry and issue a new token.

I deliberately excluded:

  • user authentication (subject tokens are self-signed by the generator — no IdP round-trip),
  • persistent-grant storage (the client is grant-only TOKEN_EXCHANGE, so the store is never touched)
  • any external policy calls.

Token exchange

Here is one measured exchange — every k6 iteration performs exactly this (secrets are redacted and tokens are truncated).

Request (the subject token is signed by the load generator, RS256):

1
2
3
4
5
6
7
8
9
10
11
POST /as/token.oauth2 HTTP/2
Host: pf-pingfederate-engine:9031
Authorization: Basic cGVyZi1hZ2VudC0zOjxwZXJmX2NsaWVudC1zZWNyZXQ->
Content-Type: application/x-www-form-urlencoded

grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange
&subject_token=eyJhbGciOiAiUlMyNTYiLCAidHlwIjogIkpXVCIs…[611 chars]
&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token
&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token
&resource=https%3A%2F%2Fmcp.example.internal%2Fmcp
&scope=mcp%3Atools%3Ainvoke

The subject token’s claims (decoded):

1
2
3
4
5
6
7
8
{
  "iss": "https://pf-perf-subject",
  "sub": "perf-user-042",
  "aud": "perf-agent-3",
  "iat": 1789978138,
  "exp": 1789978438,
  "jti": "mcp-final-1"
}

Response:

The issued access token is an RS256 at+jwt signed with PingFederate’s centralized signing key. Header (decoded):

1
2
3
4
5
6
{
  "alg": "RS256",
  "kid": "ejOM-MOWkc272nB1UwDyUGPasY4_RS256",
  "pi.atm": "8dzf",
  "typ": "at+jwt"
}

Payload (decoded):

1
2
3
4
5
6
7
8
9
10
11
12
13
{
  "scope": "mcp:tools:invoke",
  "authorization_details": [],
  "client_id": "perf-agent-3",
  "iss": "https://pf-pingfederate-engine",
  "iat": 1789978138,
  "sub": "perf-user-042",
  "aud": "https://mcp.example.internal/mcp",
  "act": {
    "sub": "perf-agent-3"
  },
  "exp": 1789978438
}

sub is the subject user, taken from the subject token’s sub through the token-exchange processor policy; act.sub is the authenticated client id, built by the access-token mapping from context.ClientId; aud is the requested resource URI, so the token is audience-restricted to the target MCP server; scope carries the requested scope, registered in the OAuth server settings — here mcp:tools:invoke; exp - iat is the token manager’s 5-minute lifetime.

The PingFederate side is fully declarative: a server profile carries all required objects as bulk-config JSON that the image imports at startup, and both engines are guaranteed identical configuration through cluster replication.

Test stages and environment

The campaign ran three reference stages — 100, 250, and 500 exchanges per second — and repeated the whole ascending ladder three times (100 → 250 → 500, ×3) against the same continuously warmed engines. Each run is five minutes at a constant arrival rate with a 30-second warmup excluded from the measured series; a throwaway sanity run before the first round is discarded by protocol, so every counted run starts from the same warmed state.

Each agent Pod drives total-rate/10 arrivals per second — at the 500 stage, 10 Pods × 50 = 500. The constant-arrival-rate executor opens each request on schedule regardless of response time, so it measures latency under controlled arrival, not throughput at saturation. All nine runs delivered their configured rates with zero dropped iterations and a 100% success rate.

Cluster

  • Amazon EKS, region eu-west-1, Kubernetes 1.35 (EKS build)
  • Six worker nodes, Amazon Linux 2023
  • All node r5.xlarge: 4 vCPU and 32 GiB each (roughly 24 vCPU of total capacity).

PingFederate sizing

Deliberately modest, close to a small production starter:

PodCPU requestCPU limitMemory requestMemory limit
pf-admin (1)122 Gi4 Gi
pf-engine (2)243 Gi6 Gi

The K8S scheduler is configured to place the two engines on different nodes when it can.

Load generation: 10 k6 Pods requesting 100m each.

How the test is run

Everything is driven by the following scripts, run from the client machine (my laptop):

  • Deployment: installs the stack into Kubernetes;
  • Verification: checks the deployment is healthy and the engines landed on different nodes;
  • Smoke test: performs a single real token exchange before any load, proving the chain works;
  • Run test: runs the actual load test and collects the results;
  • Reporting: builds the dashboard.

Alongside them, the monitoring script samples Pod and node resource usage into a CSV while the test runs. The scripts are not part of the measured path: they only observe the cluster through kubectl and collect results. Everything that generates or serves load runs inside Kubernetes; everything that coordinates, copies, and computes runs outside.

The run itself, step by step:

  • Before anything starts, one terminal is started to watch the cluster. Every 5 seconds it asks Kubernetes what the PingFederate Pods are consuming — CPU, memory — and writes each answer into a CSV. This runs the whole time, in the background (Monitoring).
  • The test starts. 10 k6 Pods begin firing token-exchange requests at PingFederate, five minutes per run, three ascending rounds per campaign. While each Pod works, k6 writes down every single request it made — timestamp, how long it took, whether it was warmup or real measurement — into a file inside its own container.
  • The five minutes end. Each k6 prints one final summary into its Pod’s log (e.g. “I sent 15,000 requests, p95 was 9 ms, everything passed.”) and then k6 quits.
  • The k6 Pods stay alive for 2 extra minutes after k6 quits and the run-test script collects all the results from the Pods — copying each agent’s per-request file out of its container; the summaries need no copying, they are already in the logs.
  • Finally, the reporting script combines everything: the per-request file from each agent, the printed totals from each log, and the resource samples from the CSV — all merged into one HTML dashboard on a shared time axis, which is where every number in this article comes from.

The results

Across the nine runs of the campaign (3 stages × 3 rounds): 780,072 token exchanges, 100% success, zero dropped iterations, all thresholds passed in every run. Not one failed or malformed OAuth response in the entire campaign.

Latency by stage

Median run of the three rounds per stage, measured phase only, warmup excluded. Every column comes from the same measured window, pooled across all ten agents:

StageTarget rateRequestsavgmedianp90p95p99
100/s100.0% achieved30,0085.4 ms5.1 ms6.9 ms7.5 ms9.7 ms
250/s100.0% achieved75,0075.2 ms4.8 ms6.6 ms7.4 ms10.8 ms
500/s100.0% achieved150,0085.9 ms5.3 ms7.7 ms9.5 ms15.3 ms

Each stage’s reported row is the median run of its three, selected by measured p95; the per-run p95s were 100/s: 7.23/7.54/7.72 ms · 250/s: 7.31/7.44/8.09 ms · 500/s: 9.03/9.51/10.01 ms — tight spreads that indicate the numbers below are not an artifact of a single lucky run.

Latency remained within a narrow range: p95 ranged from 7.4 to 9.5 milliseconds between 100 and 500 exchanges per second.

Latency and CPU over time at 500 exchanges per second

The two charts below are the Report Generator’s time-series output for the 500 tps run (the first of the three rounds at that level).

Response time (in ms) Response time p90 over time at the 500-per-second stage. After the warmup, the p90 hovers between 6 and 8 milliseconds with brief one-second spikes, and stays there for the rest of the run.

Combined CPU load of the PingFederate engines (in millicores) Combined engine CPU of both PingFederate engine Pods during the 500-per-second stage.

Combined engine CPU holds steady at about 2.5 cores.

Resource consumption

Median run of the three rounds per stage:

StageEngine CPU avgEngine CPU peakEngine CPU per exchange
100/s0.56 cores0.62 cores5.6 CPU-ms
250/s1.28 cores1.36 cores5.1 CPU-ms
500/s2.62 cores2.72 cores5.2 CPU-ms

The last column is the CPU-per-exchange figure: it is derived by dividing the aggregate CPU consumed by the PingFederate engines by the achieved exchange rate. It should therefore be interpreted as an observed CPU-efficiency metric for this workload and environment, not as a direct measurement of the incremental CPU cost of a single token exchange. Fixed JVM and platform CPU overhead is included in the figure.

Memory consumption is low: the two engine Pods together sat at a flat 2.8 GiB across all stages.

Key takeaways

In this test configuration, the token-exchange step remained in the single-digit-millisecond range at p95 for most of the tested range, reaching 9.5 ms at 500 exchanges per second. In agentic workflows where LLM reasoning and multi-step execution operate on much longer timescales, this makes the measured identity hop small relative to the overall interaction.


Resources

This post is licensed under CC BY 4.0 by the author.