Join our upcoming Webinar on Passkeys for Australian Enterprises
passkey performance testing

Passkeys Performance Testing

Learn to performance-test Passkey (FIDO2/WebAuthn) authentication using k6 and Corbado’s extension. Ensure fast logins and handle traffic spikes seamlessly.

Blog-Post-Author

Stefan

Created: April 29, 2025

Updated: April 30, 2025


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

Passkeys (a.k.a. FIDO2 / WebAuthn credentials) promise password‑less logins that are both 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, 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, 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:

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/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 like phone or Security Key).
  • NewRelyingParty(name, id, origin): Constructs a WebAuthn Relying Party (RP) object linked to your domain and origin.
  • CreateAttestationResponse(credential, rp, attestationOptions): Produces an attestation response typically created by navigator.credentials.create().
  • CreateAssertionResponse(credential, rp, assertionOptions): Produces an 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.

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:

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

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

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

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

3.3 Build docker image#

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):

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):

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 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:

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):

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:

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:

./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.

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

./k6 run examples/login.js

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:

./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.

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 => both the average and p95 are below 1ms, which is extremely fast.
  • http_req_failed: Number of failed HTTP requests => 0 requests failed, as we already know from the total results.
  • http_reqs: Total number of HTTP requests => 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 => the average is below 1ms and p95 is slightly above 1ms, which is extremely fast.
  • iterations: Total number of iterations => 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 => 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 utilization (e.g., operations per second, latency, read and write throughput)
    • Cache hit rates (e.g., InnoDB buffer pool in MySQL)
    • 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 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!

Add passkeys to your app in <1 hour with our UI components, SDKs & guides.

Start for free

Share this article


LinkedInTwitterFacebook

Enjoyed this read?

🤝 Join our Passkeys Community

Share passkeys implementation tips and get support to free the world from passwords.

🚀 Subscribe to Substack

Get the latest news, strategies, and insights about passkeys sent straight to your inbox.