DEV Community

Build With Amaobi
Build With Amaobi

Posted on

How Do You Load Test an API With Login? A Real k6 Example With JWT

Most k6 tutorials I found point the script at a demo site and call http.get() on a page that doesn't need a login. That's fine for learning the syntax. It doesn't help much when your real API has signup, login, tokens, and users who can only touch their own data.

So I built a small API with all of that and load tested it with k6. This post covers the script, the output, and two mistakes I made along the way. One of them made my API look ten times faster than it was.

The code is on GitHub: k6-load-test-api-with-login. If you'd rather watch than read, here's the video version:

Short answer, if that's all you need: call your login endpoint in the k6 script, read the token from the response with res.json("access_token"), and send it as an Authorization: Bearer <token> header on every protected request. Give each virtual user a unique email so signups don't collide. Then add thresholds so k6 can fail the test on its own.

The rest of this post shows how that works in practice.

The API we're testing

It's a FastAPI app with SQLite, bcrypt for passwords, and JWT for auth. Nothing fancy, but it has the parts that tend to break under load.

Method Path Auth
POST /auth/signup no
POST /auth/login no, returns a JWT
GET /users/me yes
POST /items yes
PATCH /items/{id} yes
GET /items/{id} yes

To run it:

git clone https://github.com/victorex27/k6-load-test-api-with-login.git
cd k6-load-test-api-with-login
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
fastapi run app/main.py
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:8000/docs and you get Swagger docs for free. There's also a Postman collection in the repo if you'd rather click through it first. I'd recommend that, because if the API doesn't work for one user, there's no point testing it with ten.

One thing to remember for later: bcrypt is slow on purpose. That's what makes stolen password hashes hard to crack. It also makes login the most expensive endpoint in this API.

Installing k6

brew install k6                    # macOS
winget install k6 --source winget  # Windows
k6 version
Enter fullscreen mode Exit fullscreen mode

I'm on k6 v2.3. The scripting API in this post works the same way it did in v1. The breaking changes in v2 were mostly CLI and cloud related.

The smallest useful k6 script

Before the real thing, here's what every k6 script looks like:

import http from "k6/http";
import { check, sleep } from "k6";

export const options = {
  vus: 1,
  duration: "10s",
};

export default function () {
  const res = http.get("http://localhost:8000/health");
  check(res, { "status is 200": (r) => r.status === 200 });
  sleep(1);
}
Enter fullscreen mode Exit fullscreen mode

options is the load: one virtual user (VU) for ten seconds. A VU is roughly one person using your API. The default function is what each VU runs, over and over, until the time is up. check is like an assertion, except it doesn't stop the test. It just records a pass or a fail. And sleep(1) is think time, because real people pause between clicks.

The real script: signup, login, then protected endpoints

Here's the full user journey. I'll go through the parts that matter after the code.

import http from "k6/http";
import { check, sleep, group } from "k6";

const BASE_URL = __ENV.BASE_URL || "http://localhost:8000";
const JSON_HEADERS = { "Content-Type": "application/json" };

export const options = {
  vus: 10,
  duration: "30s",
  thresholds: {
    http_req_failed: ["rate<0.01"],
    http_req_duration: ["p(95)<500"],
    checks: ["rate>0.99"],
    "http_req_duration{name:POST /auth/signup}": ["p(95)<500"],
    "http_req_duration{name:POST /auth/login}": ["p(95)<500"],
    "http_req_duration{name:GET /users/me}": ["p(95)<200"],
    "http_req_duration{name:POST /items}": ["p(95)<200"],
    "http_req_duration{name:PATCH /items/{id}}": ["p(95)<200"],
    "http_req_duration{name:GET /items/{id}}": ["p(95)<200"],
  },
};

export default function () {
  const email = `user_${__VU}_${__ITER}_${Date.now()}@example.com`;
  const password = "SuperSecret123!";
  let token;
  let itemId;

  group("1. signup", () => {
    const res = http.post(
      `${BASE_URL}/auth/signup`,
      JSON.stringify({ email, password, full_name: `k6 user ${__VU}` }),
      { headers: JSON_HEADERS, tags: { name: "POST /auth/signup" } },
    );
    check(res, { "signup: 201": (r) => r.status === 201 });
  });

  group("2. login", () => {
    const res = http.post(
      `${BASE_URL}/auth/login`,
      JSON.stringify({ email, password }),
      { headers: JSON_HEADERS, tags: { name: "POST /auth/login" } },
    );
    check(res, {
      "login: 200": (r) => r.status === 200,
      "login: has token": (r) => !!r.json("access_token"),
    });
    token = res.json("access_token");
  });

  if (!token) {
    sleep(1);
    return;
  }

  const authHeaders = { ...JSON_HEADERS, Authorization: `Bearer ${token}` };

  group("3. get my profile", () => {
    const res = http.get(`${BASE_URL}/users/me`, {
      headers: authHeaders,
      tags: { name: "GET /users/me" },
    });
    check(res, {
      "me: 200": (r) => r.status === 200,
      "me: correct email": (r) => r.json("email") === email,
    });
  });

  group("4. create item", () => {
    const res = http.post(
      `${BASE_URL}/items`,
      JSON.stringify({ name: "Keyboard", price: 120.5, quantity: 1 }),
      { headers: authHeaders, tags: { name: "POST /items" } },
    );
    check(res, { "create item: 201": (r) => r.status === 201 });
    itemId = res.json("id");
  });

  group("5. update item", () => {
    const res = http.patch(
      `${BASE_URL}/items/${itemId}`,
      JSON.stringify({ price: 99.99, quantity: 3 }),
      { headers: authHeaders, tags: { name: "PATCH /items/{id}" } },
    );
    check(res, {
      "update item: 200": (r) => r.status === 200,
      "update item: price changed": (r) => r.json("price") === 99.99,
    });
  });

  group("6. get item", () => {
    const res = http.get(`${BASE_URL}/items/${itemId}`, {
      headers: authHeaders,
      tags: { name: "GET /items/{id}" },
    });
    check(res, { "get item: quantity is 3": (r) => r.json("quantity") === 3 });
  });

  sleep(1);
}
Enter fullscreen mode Exit fullscreen mode

Unique emails, or your signups fail

Ten users all signing up as test@test.com gets you one 201 and a pile of 409 Conflicts. __VU is the virtual user's number and __ITER counts how many times that user has looped, so combining them (plus a timestamp) gives every signup its own address.

Pulling the token out of the login response

res.json("access_token") reads the token from the login response. Then it goes into an Authorization: Bearer header for every request after that. Load testing tools call this correlation: you take a value from one response and use it in the next request. Pretty much every authenticated load test depends on it.

If login fails there's no token, so the script sleeps and returns early. That sleep(1) before the return matters, and I'll explain why in a minute.

Check the data, not just the status code

"Price changed" checks that price really is 99.99 after the PATCH. Under load, a 200 with stale data is still a bug, and a status-code-only check won't catch it.

The name tag

Without it, k6 would report /items/1, /items/2, /items/3... as thousands of separate URLs. The tag groups them under one label, GET /items/{id}.

Running it

k6 run k6/02-user-journey.js
Enter fullscreen mode Exit fullscreen mode

On my machine, ten users ran the full journey about 260 times in 30 seconds. That's around 1,500 requests. Every threshold passed. Overall p95 was about 110ms. Login sat around 120ms and signup around 160ms, while the item endpoints stayed between 5 and 12ms.

If you haven't run into p95 before: it's the response time that 95% of requests beat. I find it more useful than the average, because the average hides your slowest users.

Gotcha #1: tagging a request doesn't make k6 show it

The first version of this script didn't have the per-endpoint thresholds. It had the three global ones and that was it. I ran it, the duration threshold went red, and I had no idea which endpoint caused it. All I had was this:

http_req_duration
✗ 'p(95)<500' p(95)=615.03ms
Enter fullscreen mode Exit fullscreen mode

I'd tagged every request, so I expected a per-endpoint breakdown. There wasn't one. In k6 v2's default summary, a tag only gets its own line in the output if a threshold targets it. Tags on their own just sit there.

So I gave every endpoint its own budget: 500ms for signup and login (hashing is slow on purpose), 200ms for everything else. You could also run with --summary-mode=full, which prints timings for each group. But thresholds are what actually fail your build, so I'd rather have them.

Breaking it on purpose

To see if the thresholds actually work, I restarted the API with bcrypt's cost factor raised from 10 to 13. That makes hashing about eight times slower. You could picture it as a well-meaning security change that nobody load tested.

BCRYPT_ROUNDS=13 fastapi run app/main.py
Enter fullscreen mode Exit fullscreen mode

Same script, new output:

http_req_duration
✗ 'p(95)<500' p(95)=1.03s
  {name:GET /items/{id}}    ✓ p(95)=5.47ms
  {name:GET /users/me}      ✓ p(95)=5.67ms
  {name:PATCH /items/{id}}  ✓ p(95)=5.95ms
  {name:POST /auth/login}   ✗ p(95)=1.06s
  {name:POST /auth/signup}  ✗ p(95)=1.17s
  {name:POST /items}        ✓ p(95)=5.65ms
Enter fullscreen mode Exit fullscreen mode

k6 exited with code 99, which would fail a CI pipeline. And you can see exactly where the problem is: signup and login are over a second, while everything else is still around 5ms. Without the per-endpoint thresholds, all you'd know is that something somewhere got slow.

Gotcha #2: fast responses don't mean much if they're errors

This is the one I'm a little embarrassed about.

My first test emails ended in @k6.test. Looked reasonable. I ran the script and got this:

checks_succeeded...: 0.00%   0 out of 129180
http_req_duration..: avg=3.45ms p(95)=5.62ms
http_reqs..........: 86120
Enter fullscreen mode Exit fullscreen mode

A p95 of 5.6 milliseconds, and 86,000 requests in 30 seconds from ten users. If I'd only been watching response times, that would've looked great.

Every single request had failed. The email validator in the API rejects .test because it's a reserved domain, so signup returned a 422 instantly. No user, so login failed, also instantly. And because my early return didn't have a sleep() in front of it back then, every VU spun in a tight loop, firing requests as fast as it could.

Two lessons from that run. Always check status codes, because an error is often the fastest response your API can give. And make sure every code path in your default function sleeps, including the early exits.

A more realistic version

The script above signs up a new user on every loop, which isn't how real people behave. They log in once and then use the app for a while.

The repo has a third script, 03-load-test.js, that does that. Each VU logs in once and keeps its token in a module-level variable (those live once per VU). It also ramps the load up and down with stages instead of holding a flat ten users:

export const options = {
  stages: [
    { duration: "30s", target: 20 },
    { duration: "1m", target: 20 },
    { duration: "20s", target: 0 },
  ],
};

let session = null; // one per VU

export default function () {
  if (!session) session = signupAndLogin();
  // ...use session.headers for every request
}
Enter fullscreen mode Exit fullscreen mode

Run it with K6_WEB_DASHBOARD=true k6 run k6/03-load-test.js and open http://localhost:5665 to watch live graphs while it runs.

FAQ

Can k6 test APIs that need authentication?
Yes. Call the login endpoint inside the script, read the token from the response, and send it as a Bearer header on the requests that need it. If your token expires during a long test, log in again when you get a 401.

How do I avoid duplicate users when load testing signup?
Build the email from __VU, __ITER and Date.now(). For a fixed set of pre-created users, load them from a CSV with SharedArray.

Why doesn't k6 show results per endpoint?
Tags alone don't add lines to the default summary. Add a threshold on the tagged sub-metric, like "http_req_duration{name:POST /auth/login}": ["p(95)<500"], or run with --summary-mode=full.

What does p95 mean in k6 output?
95% of requests were faster than that number. It's a better guide to how your API feels than the average.

What's a k6 threshold?
A pass/fail rule on a metric. If any threshold fails, k6 exits with a non-zero code, so a CI job running it fails too.

Wrapping up

If you take one thing from this: load test the flows your users actually go through, including login, and set thresholds per endpoint so a failure tells you where to look.

All the code (the API, three k6 scripts, and a Postman collection) is here: github.com/victorex27/k6-load-test-api-with-login.

The full walkthrough is on YouTube: How Do You Load Test an API With Login?

Next I'm planning to run this in GitHub Actions so every pull request gets load tested. If there's something you'd rather see first, tell me in the comments. I'm also curious which endpoint is slowest in your API.

Top comments (0)