---
url: 'https://www.corbado.com/blog/passkey-performance-testing'
title: 'Passkeys Performance Testing'
description: 'Learn to performance-test Passkey (FIDO2/WebAuthn) authentication using k6 and Corbado’s extension. Ensure fast logins and handle traffic spikes seamlessly.'
lang: 'en'
author: 'Stefan'
date: '2025-04-29T12:31:32.909Z'
lastModified: '2026-03-27T07:01:25.475Z'
keywords: 'Passkey load testing, WebAuthn performance, performance testing, load testing, k6 load test, Corbado passkeys, authentication scalability, login performance optimization, passwordless login testing, passk'
category: 'Authentication'
---

# Passkeys Performance Testing

## Key Facts

- Performance-testing passkey authentication requires simulating the full **multi-step
  ceremony** (challenge generation, signature verification and attestation), not just a
  single HTTP endpoint.
- Corbado's **xk6-passkeys extension** enables k6 to forge WebAuthn attestation and
  assertion objects without a physical authenticator, producing thousands of credentials
  per second.
- Real-world enterprise **registration flow** takes 500-1000ms in production; a local
  benchmark against an in-memory Go backend achieved average and p95 durations below 1ms.
- **SLO thresholds** such as `p(95)<1000ms` and `failed<1%` should be encoded as k6 checks
  to block deployments on performance regressions.
- **Registration ceremonies are heavier than login** assertions: size capacity plans for
  the registration storm scenario to ensure login surges stay within budget.

## 1. Introduction: Why Performance-Test Passkey Authentication?

Passkeys (a.k.a. [FIDO2](https://www.corbado.com/glossary/fido2) / WebAuthn credentials) promise password‑less
logins that are both [phishing](https://www.corbado.com/glossary/phishing)‑resistant and friction‑free. But that
new cryptographic handshake comes with a different server‑side profile than the familiar
`POST /login` with a password hash check. Each registration and login now triggers a
multi‑step ceremony: challenge generation, signature verification, potentially
hardware‑backed [attestation](https://www.corbado.com/glossary/attestation), and often a call to an external
identity platform. When traffic spikes, those extra hops can become the long pole in your
critical path.

**Pain point:** as user adoption climbs and marketing drives short‑lived surges (product
launches, Black‑Friday sales, viral campaigns), the same passkey flow that felt instant in
QA can morph into seconds‑long waits—or worse, 5xx errors that block conversion entirely.

**Value proposition:** proactive load-testing lets you simulate tomorrow’s traffic today.
By stress‑testing the full passkeys ceremony you uncover:

- bottlenecks in challenge generation or signature verification code
- container auto‑scaling thresholds that are too conservative
- misconfigured TPM rate limits
- hidden dependencies such as a slow external identity API

Finding (and fixing) those issues early preserves a sub‑1000 ms login experience, protects
revenue at peak load, and shields your customer‐support team from an avalanche of “I can’t
sign in” tickets.

If you’re new to [passkey testing](https://www.corbado.com/blog/testing-passkeys), check the deep‑dive here.

Below are the guiding questions we’ll answer as we work through this article:

- **Concurrency:** How can I test the concurrency to determine how many simultaneous
  passkey registrations or logins my service can currently handle?
- **Latency headroom:** How can I measure the latency of my endpoints to ensure that
  registration and login times remain under 1000 ms when traffic increases by 10×?
- **Error rates:** What is the expected number of errors in my passkeys endpoints under
  load?

By the end of this article you’ll have a reproducible harness you can point at staging—or
prod in a maintenance window—to answer all three.

This article is tailored for backend engineers managing passkeys endpoints and SREs
overseeing capacity and on-call alerts. If you have a knack for reading Go and
orchestrating Docker containers, you're all set to dive in.

## 2. Tooling Overview: k6 + Passkeys Extension

### 2.1 k6 in 90 seconds

k6 is an OSS load‑testing runner written in Go with a JavaScript scripting API. You
describe virtual‑user (VU) behavior in a JS file, point k6 at your target, and read rich,
time‑series metrics in real time or via Prometheus/Grafana. Key concepts:

- **VU (Virtual User):** A lightweight goroutine that executes your JS step‑by‑step.
- **Iteration:** One full loop of the default function.
- **Stages / Ramping:** Configure arrival rate or VUs over time.
- **Built‑in metrics:** `http_req_duration`, `vus`, `iterations`, `checks` etc.

Example skeleton we’ll evolve later:

```js
import http from "k6/http";
import { sleep } from "k6";

export const options = {
    vus: 50,
    duration: "1m",
};

export default function () {
    http.get("https://example.com");
}
```

### 2.2 The Corbado k6 Passkeys Extension

A passkey ceremony is not a single HTTP call; it’s a challenge → client‑side signature →
[attestation](https://www.corbado.com/glossary/attestation)/[assertion](https://www.corbado.com/glossary/assertion) exchange. Vanilla k6
can issue HTTP, but it can’t forge passkeys (WebAuthn) objects. Corbado’s extension fills
that gap by giving your JS script first‑class helpers:

- **NewCredential():** Generates an in-memory passkey, consisting of a public/private key
  pair (in a real-world scenario, the passkey is generated and stored by the user's
  [authenticator](https://www.corbado.com/glossary/authenticator) like phone or
  [Security Key](https://www.corbado.com/glossary/security-key)).
- **NewRelyingParty(name, id, origin):** Constructs a WebAuthn
  [Relying Party](https://www.corbado.com/glossary/relying-party) (RP) object linked to your domain and origin.
- **CreateAttestationResponse(credential, rp, attestationOptions):** Produces an
  [attestation](https://www.corbado.com/glossary/attestation) response typically created by
  `navigator.credentials.create()`.
- **CreateAssertionResponse(credential, rp, assertionOptions):** Produces an
  [assertion](https://www.corbado.com/glossary/assertion) response typically created by
  `navigator.credentials.get()`.

**Tip:** The helpers are deterministic and CPU‑light, so you can create thousands of
credentials per second without touching a physical
[authenticator](https://www.corbado.com/glossary/authenticator).

### 2.3 Building the k6 passkeys extension

Ensure you have the following prerequisites:

- **Git:** Clone the repository & pull updates.
- **Go 1.23:** Compile k6 (written in Go) and the passkeys extension
- **Make:** Used to build the k6 passkeys extension.

To begin, clone the repository:

```bash
git clone https://github.com/corbado/xk6-passkeys
```

Now building the extension is as easy as running `make build`:

```bash
make build
```

This command will generate a `k6` binary in the current directory with the extension
included.

## 3. Reference Backend for the Tests

### 3.1 Why ship a toy server?

Meaningful load-testing need a counterpart that speaks the same passkeys dialect
(WebAuthn) as your production stack. To keep this tutorial self‑contained we include a
300‑line Go service you can boot with one Docker command. The server:

- listens on **:8080**
- stores credentials in an in‑memory map - no external DB
- validates signatures via go-webauthn

Swap in your staging cluster later; the k6 scripts won’t need edits as long as the
endpoint contract stays the same.

### 3.2 Endpoint map

- **GET /register/start/:username:** Issues `PublicKeyCredentialCreationOptions` challenge
- **POST /register/finish/:username:** Verifies attestation, stores credential
- **GET /login/start/:username:** Issues `PublicKeyCredentialRequestOptions` challenge
- **POST /login/finish/:username:** Verifies [assertion](https://www.corbado.com/glossary/assertion)

All payloads are JSON and follow the WebAuthn Level‑2 spec.

### 3.3 Build docker image

```bash
docker build -t passkeys-backend examples/backend
```

## 4. Building the Load-Testing Scenarios

We model two extremes that cover 95 % of real‑world traffic patterns:

1. **Registration Storm** 📈 – a burst of new users (think launch day or a viral press
   mention).
2. **Login Surge** ⚡️ – a pile‑on of returning users (think morning peak or an outage
   recovery).

To keep scripts tidy we factor three tiny helpers into `helper.js` (full source in the
[repository](https://github.com/corbado/xk6-passkeys/blob/main/examples/helper.js)):

```js
import { check, fail } from "k6";

/**
 * Generates a random string of a given length
 * @param {number} length - The length of the string to generate
 * @returns {string} - A random string of the specified length
 */
export function randomString(length) {
    return Array.from({ length }, () => {
        const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
        return chars.charAt(Math.floor(Math.random() * chars.length));
    }).join("");
}

/**
 * Success helper that calls k6 check with true to count for an successful iteration
 */
export function success() {
    check(true, { success: (r) => r === true });
}

/**
 * Failure helper that calls k6 check with false to count for an failed iteration
 * @param {string} message - The message to log
 */
export function failure(message) {
    check(false, { success: (r) => r === true });
    fail(message);
}
```

### 4.1 Registration Storm

Passkey registration ceremony recap:

- **Start** – Client requests `PublicKeyCredentialCreationOptions` from
  `/register/start/:username`; server embeds a random challenge.
- **Client‑side creation** – Browser (or in our case the passkeys extension) calls
  `navigator.credentials.create()` → signs the challenge and returns an attestation plus a
  new credential public key.
- **Finish** – Client POSTs that attestation to `/register/finish/:username`; server
  verifies the signature and persists the credential.

This entire process is translated into a k6 script `registration.js` for load-testing the
registration flow, as shown below (full source in the
[repository](https://github.com/corbado/xk6-passkeys/blob/main/examples/registration.js)):

```js
export default function () {
    const username = randomString(20);

    // Step 1: Start registration
    const startResponse = http.get(`${baseUrl}/register/start/${username}`, {
        tags: { name: "start" },
    });
    if (startResponse.status !== 200) {
        failure(
            `Request to register/start failed with status ${startResponse.status} (body: ${startResponse.body})`,
        );
    }

    // Step 2: Create attestation response (simulate the client
    // side and call to navigator.credentials.create())
    const credential = passkeys.newCredential();
    const attestationResponse = passkeys.createAttestationResponse(
        rp,
        credential,
        JSON.stringify(startResponse.json()),
    );

    // Step 3: Finish registration
    const finishResponse = http.post(
        `${baseUrl}/register/finish/${username}`,
        attestationResponse,
        {
            headers: { "Content-Type": "application/json" },
            tags: { name: "finish" },
        },
    );
    if (finishResponse.status !== 200) {
        failure(
            `Request to register/finish failed with status ${finishResponse.status} (body: ${finishResponse.body})`,
        );
    }

    success();
}
```

### 4.2 Login Surge

[Passkey login](https://www.corbado.com/blog/passkey-login-best-practices) ceremony recap:

- **Start** – Client hits `/login/start/:username`; server returns
  `PublicKeyCredentialRequestOptions` with a fresh challenge and credential IDs allowed
  for that user.
- **Client‑side assertion** – Browser (or in our case the passkeys extension) calls
  `navigator.credentials.get()` → signs the challenge with the stored private key,
  producing an assertion.
- **Finish** – Client POSTs the assertion to `/login/finish/:username`; server validates
  signature & RPID hash and issues a session/JWT for example.

Registration and login are distinct processes, and since we cannot store credentials
(passkeys) locally, particularly the private key, we must first create a single
user/credential. We achieve this using the k6 setup function:

```js
export function setup() {
    const username = randomString(20);

    // Step 1: Start registration
    const startResponse = http.get(`${baseUrl}/register/start/${username}`);
    if (startResponse.status !== 200) {
        throw new Error(
            `Request to register/start failed with status ${startResponse.status} (body: ${startResponse.body})`,
        );
    }

    // Step 2: Create attestation response (simulate the client
    // side and call to navigator.credentials.create())
    const credential = passkeys.newCredential();
    const attestationResponse = passkeys.createAttestationResponse(
        rp,
        credential,
        JSON.stringify(startResponse.json()),
    );

    // Step 3: Finish registration
    const finishResponse = http.post(
        `${baseUrl}/register/finish/${username}`,
        attestationResponse,
        {
            headers: { "Content-Type": "application/json" },
        },
    );

    if (finishResponse.status !== 200) {
        throw new Error(
            `Request to register/finish failed with status ${finishResponse.status} (body: ${finishResponse.body})`,
        );
    }

    // We need to stringify the credential to avoid the
    // "invalid credential" error (did not find the root cause yet)
    return { username, credential: JSON.stringify(credential) };
}
```

Next, we utilize this single test user/credential to develop our k6 script `login.js` for
load-testing the login process (full source in the
[repository](https://github.com/corbado/xk6-passkeys/blob/main/examples/login.js)):

```js
export default function (data) {
    const { username } = data;
    const credential = JSON.parse(data.credential);

    // Step 1: Start login
    const startResponse = http.get(`${baseUrl}/login/start/${username}`, {
        tags: { name: "start" },
    });
    if (startResponse.status !== 200) {
        failure(
            `Request to login/start failed with status ${startResponse.status} (body: ${startResponse.body})`,
        );
    }

    // Step 2: Create assertion response (simulate the client
    // side and call to navigator.credentials.get())
    const assertionResponse = passkeys.createAssertionResponse(
        rp,
        credential,
        username,
        JSON.stringify(startResponse.json()),
    );

    // Step 3: Finish login
    const finishResponse = http.post(
        `${baseUrl}/login/finish/${username}`,
        assertionResponse,
        {
            headers: { "Content-Type": "application/json" },
            tags: { name: "finish" },
        },
    );
    if (finishResponse.status !== 200) {
        failure(
            `Request to login/finish failed with status ${finishResponse.status} (body: ${finishResponse.body})`,
        );
    }

    success();
}
```

## 5. Running the Tests

### 5.1 Run locally

Launch the backend using the following Docker command:

```bash
docker run --rm -p 8080:8080 passkeys-backend
```

⚠️ **Important: The** `docker run` **command is blocking. To ensure the backend operates
throughout the entire test duration, it must be executed in a separate terminal.**

Run the k6 script to simulate a registration surge:

```bash
./k6 run examples/registration.js
```

This script will utilize 2 virtual users (VUs) and execute for 30 seconds. Feel free to
adjust these numbers to experiment.

![Screenshot of k6 Registration Test Results](k6_results_registration.png)

To simulate a login surge, simply change the script name:

```bash
./k6 run examples/login.js
```

![Screenshot of k6 Login Test Results](k6_results_login.png)

### 5.2 Run in CI/CD

To continuously monitor the performance of your passkeys endpoints, you should run tests
in your CI/CD pipeline. This process heavily depends on your specific CI/CD setup, but to
configure k6 to output its report as JSON, use the following command:

```bash
./k6 run examples/login.js --summary-export=summary.json
```

This will generate a `summary.json` file in the current directory. You can use this file
to perform checks or visualize the results in your CI/CD pipeline.

k6 provides many additional options. For more information, refer to their
[documentation](https://grafana.com/docs/k6/latest/get-started/results-output/).

## 6. Interpreting the Results

### 6.1 k6 Summary Report

Since the numbers for registration and login are quite similar, we will focus on the
registration test results.

The k6 summary report is divided into multiple sections, each with its own metrics. We
will review each of them.

It is important to understand the difference between the number of HTTP requests and the
number of iterations. HTTP requests are individual requests to our backend, counting each
one separately. Iterations refer to how many times our k6 scenario was executed. Since our
scenario uses two HTTP requests (one for start and one for finish), we have 2 HTTP
requests per iteration.

#### 6.1.1 TOTAL RESULTS

We completed a total of 63,406 iterations, which equates to approximately 2,113 iterations
per second. Impressively, 100% of them were successful, meaning all HTTP requests to our
backend resulted in a 200 status code (as opposed to 5xx or 4xx).

#### 6.1.2 HTTP

The following metrics are displayed here:

- **http_req_duration:** Statistics (average, min, max, percentiles) of the HTTP request
  duration =&gt; both the average and p95 are below 1ms, which is extremely fast.
- **http_req_failed:** Number of failed HTTP requests =&gt; 0 requests failed, as we
  already know from the total results.
- **http_reqs:** Total number of HTTP requests =&gt; 126,812 requests were made, which
  translates to 4,227 requests per second. This is roughly double the number of
  iterations, as expected.

#### 6.1.3 EXECUTION

The execution section largely mirrors the HTTP section but focuses on iterations instead
of HTTP requests:

- **iteration_duration:** Statistics (average, min, max, percentiles) of the iteration
  duration =&gt; the average is below 1ms and p95 is slightly above 1ms, which is
  extremely fast.
- **iterations:** Total number of iterations =&gt; 63,406 iterations were made, which
  equates to approximately 2,113 iterations per second (consistent with the total
  results).
- **vus** and **vus_max:** The number of VUs used during load-testing =&gt; since we have
  a static configuration (`options`) of 2, this is always 2.

#### 6.1.4 NETWORK

This section provides additional statistics on the amount of data received and sent.

### 6.2 Backend Metrics

We have not covered backend metrics here. In a real-world scenario, you would also examine
backend metrics, such as:

- Database metrics
    - CPU utilization
    - Memory utilization
    - [IO](https://www.corbado.com/blog/webauthn-errors) utilization (e.g., operations per second, latency, read
      and write throughput)
    - Cache hit rates (e.g., InnoDB buffer pool in
      [MySQL](https://www.corbado.com/blog/passkey-webauthn-database-guide))
    - Lock metrics (statistics about locked tables and rows, wait timeouts, etc.)
    - Queries per second
- Container or server metrics
    - CPU utilization
    - Memory utilization

### 6.3 Interpretation

As you can see, the results for error rates, average and p95 response times, and requests
per second are excellent. This is because we:

- Run the tests entirely locally (k6 and the backend are on the same machine).
- Implemented the backend in Go, which is very efficient and lightweight.
- Used only in-memory storage in the backend, so there are no real persistence and
  database operations.
- Did not implement additional business logic in the backend (such as more database
  operations, external API calls, etc.).

In real-world applications or production environments, you are unlikely to achieve the
same results. Based on our experience with enterprise customers, and considering the
additional business logic implemented, a realistic expectation for a complete registration
flow is between 500 and 1000 milliseconds.

## 7. Conclusion

### 7.1 Where we landed on the three guiding questions

The introductory questions regarding concurrency, latency, and error rates have been
thoroughly addressed. We have provided a robust setup that allows you to conduct your own
load-testing using k6 with the Corbado k6 Passkeys extension. Additionally, we have
detailed the setup process and offered guidance on interpreting the results.

### 7.2 Key takeaways

1. **Load‑test the ceremony, not just the endpoint.** Passkeys registration and login is a
   multi‑step process that must be simulated faithfully; Corbado’s k6 Passkeys extension
   makes that painless.
2. **Registration is heavier than login.** Size your capacity plan for the storm scenario,
   then the surge will be fine.
3. **Thresholds turn tests into gates.** Encode your SLOs (`p(95)<1000`, `failed<1%`) so
   performance regressions break the build, not production.

### 7.3 Next steps

- **Point the scripts at your staging URL** and
  [watch](https://www.corbado.com/blog/how-to-use-passkeys-apple-watch) the numbers change.
- **Tweak the scenarios** — add think‑time, ramp arrival rates, or feed a credential pool.
- **Open a PR or issue** with your findings; we love real‑world numbers.

Happy load‑testing, and may your login buttons stay green under fire!

## Frequently Asked Questions

### How should I structure a k6 load test specifically for the passkey login ceremony?

The passkey login ceremony requires a k6 setup() function to register one credential
before the test begins, since the private key must persist across virtual users. Each
iteration then calls login/start to fetch a fresh challenge and login/finish to submit a
CreateAssertionResponse(), covering the full two-step assertion flow.

### Why is passkey registration more resource-intensive than login when load testing?

Registration involves challenge generation, attestation verification and credential
persistence, making it computationally heavier than login's two-step assertion
verification. Capacity planning should target the registration storm scenario first: if
the backend handles that peak, login surges will remain within limits.

### How do I integrate passkey load tests into a CI/CD pipeline using k6?

Run k6 with the --summary-export=summary.json flag to produce a machine-readable report
after each test run. Encode SLO thresholds such as `p(95)<1000ms` and `failed<1%` as k6
check conditions so performance regressions automatically fail the pipeline before
reaching production.

### What response times should I realistically expect from a passkey authentication endpoint under production load?

Based on enterprise customer deployments, a complete passkey registration flow takes
500-1000ms in production, accounting for business logic, database operations and external
API calls. Local benchmarks against an in-memory Go backend show sub-1ms averages, but
those results do not reflect real-world conditions involving persistence and external
dependencies.
