Guides / k6

Load testing an OTP login flow with k6

Updated October 8, 2026
Short answer

Point your app's SMS provider at a mock inbox so a load test sends no real SMS. In the k6 script, each iteration builds a unique phone number, notes the time, calls your login start endpoint, polls the inbox with http.get until the code arrives and posts it to the verify endpoint. Each SMS counts as one message on your plan, so size the test before you run it.

Why mock the SMS in a load test

A login load test with real SMS costs money per message, hits carrier rate limits and measures the carrier more than your system. With a mock, the provider call returns quickly and you measure your own code: rate limiting, OTP storage, verification and session creation. Point your provider's SDK at otpmock with one option in the load test environment; each provider's option is on its provider page.

The inbox API

HTTP
GET https://api.otpmock.com/v1/inbox/{phone}/code?since={unix_ms}
Authorization: Bearer $OTPMOCK_API_KEY

200 {"code":"482913","messageSid":"SM…","body":"Your code is 482913","receivedAt":1791417652342}
404 {"error":"no_code_yet"}

The script

Every VU builds its number from exec.vu.idInTest and exec.vu.iterationInScenario plus random digits, so no two iterations share an inbox. Polling requests get their own tag, so they don't skew your login metrics, and 404 is marked as an expected status.

otp-login.js
import http from 'k6/http';
import exec from 'k6/execution';
import { check, fail, sleep } from 'k6';
import { Trend } from 'k6/metrics';

const APP = __ENV.APP_URL;                      // e.g. https://staging.example.com
const OTP = __ENV.OTPMOCK_URL || 'https://api.otpmock.com';
const AUTH = { Authorization: `Bearer ${__ENV.OTPMOCK_API_KEY}` };
const codeDelivery = new Trend('otp_delivery_ms', true);

export const options = {
  scenarios: {
    login: { executor: 'constant-vus', vus: 20, duration: '2m' },
  },
  thresholds: {
    'http_req_duration{name:verify}': ['p(95)<500'],
    checks: ['rate>0.99'],
  },
};

function uniquePhone() {
  const vu = String(exec.vu.idInTest % 1000).padStart(3, '0');
  const rand = String(Math.floor(Math.random() * 10000)).padStart(4, '0');
  return `+1555${vu}${rand}`;
}

function waitForCode(phone, since, timeoutMs = 15000) {
  const url = `${OTP}/v1/inbox/${encodeURIComponent(phone)}/code?since=${since}`;
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    const res = http.get(url, {
      headers: AUTH,
      tags: { name: 'otpmock_poll' },
      responseCallback: http.expectedStatuses(200, 404),
    });
    if (res.status === 200) {
      codeDelivery.add(Date.now() - start);
      return res.json('code');
    }
    if (res.status !== 404) fail(`otpmock ${res.status}`);
    sleep(0.3);
  }
  return null;
}

export default function () {
  const phone = uniquePhone();
  const since = Date.now() - 5000;              // clock skew margin

  const start = http.post(`${APP}/auth/phone/start`, JSON.stringify({ phone }), {
    headers: { 'Content-Type': 'application/json' }, tags: { name: 'start' },
  });
  check(start, { 'start 2xx': (r) => r.status >= 200 && r.status < 300 });

  const code = waitForCode(phone, since);
  if (!check(code, { 'code received': (c) => !!c })) return;

  const verify = http.post(`${APP}/auth/phone/verify`, JSON.stringify({ phone, code }), {
    headers: { 'Content-Type': 'application/json' }, tags: { name: 'verify' },
  });
  check(verify, { 'verified': (r) => r.status === 200 });
  sleep(1);
}

Run it with k6 run -e APP_URL=https://staging.example.com -e OTPMOCK_API_KEY=... otp-login.js. The custom otp_delivery_ms trend shows how long your app takes to hand the SMS to the provider under load, which is often where queues back up.

Size the test to your plan

Every SMS your app sends to otpmock counts as one message; inbox reads do not. Estimate before running: 20 VUs looping for 2 minutes with about 2 seconds per iteration is roughly 1,200 logins, so 1,200 messages. That fits a paid plan, not the free plan's 100 messages a month. To stay small, use shared-iterations with a fixed iterations count, which caps messages exactly. Enterprise plans are unlimited under fair use; tell us before a very large run so we can plan capacity with you.

options for a capped run
export const options = {
  scenarios: {
    login: { executor: 'shared-iterations', vus: 10, iterations: 200, maxDuration: '5m' },
  },
};

TLS with a private CA

k6 is a Go binary. On Linux it reads extra roots from SSL_CERT_FILE (or SSL_CERT_DIR), so SSL_CERT_FILE=/path/to/ca.pem k6 run ... trusts a staging CA without code changes. The insecureSkipTLSVerify: true option also works but turns off all certificate checks, so keep it out of anything except throwaway environments.

Common pitfalls

FAQ

Does polling the inbox count against my quota?

No. Only SMS sent to otpmock count as messages.

Can I run this in Grafana Cloud k6?

Yes. The script is plain k6; pass the API key as a cloud environment variable or secret.

Should I skip the SMS step entirely in load tests?

You can, with a backdoor code, but then you never load test the code path that stores and sends OTPs. A mock keeps that path real.

Try it with a small run first

The free plan includes 100 messages a month, enough for a capped smoke run. No card required.

Get a free API key

Related guides