Learn to performance-test Passkey (FIDO2/WebAuthn) authentication using k6 and Corbado’s extension. Ensure fast logins and handle traffic spikes seamlessly.
Stefan
Created: April 29, 2025
Updated: April 30, 2025
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:
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:
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.
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:
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"); }
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:
navigator.credentials.create()
.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.
Ensure you have the following prerequisites:
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.
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:
Swap in your staging cluster later; the k6 scripts won’t need edits as long as the endpoint contract stays the same.
PublicKeyCredentialCreationOptions
challengePublicKeyCredentialRequestOptions
challengeAll payloads are JSON and follow the WebAuthn Level‑2 spec.
docker build -t passkeys-backend examples/backend
We model two extremes that cover 95 % of real‑world traffic patterns:
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); }
Passkey registration ceremony recap:
PublicKeyCredentialCreationOptions
from
/register/start/:username
; server embeds a random challenge.navigator.credentials.create()
→ signs the challenge and returns an attestation plus a
new credential public key./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(); }
Passkey login ceremony recap:
/login/start/:username
; server returns
PublicKeyCredentialRequestOptions
with a fresh challenge and credential IDs allowed
for that user.navigator.credentials.get()
→ signs the challenge with the stored private key,
producing an assertion./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(); }
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
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.
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.
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).
The following metrics are displayed here:
The execution section largely mirrors the HTTP section but focuses on iterations instead of HTTP requests:
options
) of 2, this is always 2.This section provides additional statistics on the amount of data received and sent.
We have not covered backend metrics here. In a real-world scenario, you would also examine backend metrics, such as:
As you can see, the results for error rates, average and p95 response times, and requests per second are excellent. This is because we:
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.
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.
p(95)<1000
, failed<1%
) so
performance regressions break the build, not production.Happy load‑testing, and may your login buttons stay green under fire!
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.
Related Articles
How to Track Cybersecurity Performance in 2025: Essential KPIs for Businesses
Vincent - December 30, 2024
Parallels Passkeys: Testing Cross Device Authentication on Windows 11 VM on a Mac
Vincent - September 20, 2024
Passkeys E2E Playwright Testing via WebAuthn Virtual Authenticator
Anders - March 30, 2024
Table of Contents