Applying load testing to real HTTP APIs: authentication headers and bearer tokens, cookies, request parameterization with dynamic data and JSON payloads, plus combined functional and performance assertions with check() and r.json().

In episode 3 you wrote your first k6 script: the basic structure, HTTP GET and POST, check(), group(), and reading the k6 run script.js output. Now we move from scripts that run to scripts that are realistic. Episode 4 focuses on one domain: basic HTTP API load testing — which means dealing with the things that always appear in production APIs: authentication, cookies, dynamic data, and JSON payloads.
Why is this episode important? Because a script that sends GET / without headers is just practice. Real APIs require tokens, send data, and return structured responses that must be validated. This episode teaches three core capabilities: (1) setting authentication headers like bearer tokens, (2) parameterizing requests with dynamic data, and (3) combining functional and performance assertions in one reliable script.
Before diving into scenarios, get to know the three call forms that will dominate your k6 career:
import http from "k6/http";
http.get("https://api.example.com/users");
http.post("https://api.example.com/login", body, params);
http.request("PUT", "https://api.example.com/users/42", body, params);All three accept params as their last argument — an object that can contain headers, params, cookies, tags, and timeout. This pattern is consistent: URL first, body (if any) second, params last — once you understand this, adding a new method is just copying the same shape.
Modern APIs authenticate requests through the Authorization header with the Bearer token scheme — a token (usually a JWT) that proves the sender's identity. The header is sent through the headers object in params:
import http from "k6/http";
import { check } from "k6";
const params = {
headers: {
Authorization: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhcm1hbiJ9.secret",
Accept: "application/json",
},
};
export default function () {
const res = http.get("https://api.example.com/profile", params);
check(res, { "profile 200": (r) => r.status === 200 });
}Get in the habit of defining params outside the default function (init phase) if the headers are static — the object isn't recreated on every iteration. If the token changes (e.g., from a login), put it in setup() following the pattern from episode 2, or recreate it per scenario (episode 12 will dissect OAuth2 and JWT in depth).
For a token stored in a variable and inserted into the header, use a template literal:
import http from "k6/http";
const token = "eyJhbGciOiJIUzI1NiJ9.payload.signature";
export default function () {
const res = http.get("https://api.example.com/users/me", {
headers: {
Authorization: `Bearer ${token}`,
},
});
}Many web applications track sessions through cookies rather than headers. k6 manages cookies automatically through a per-VU cookie jar: cookies received from responses (Set-Cookie) are automatically sent back on subsequent requests to the same domain — so a "login then access a page" flow works without any extra code.
If you need to send a cookie explicitly, use the cookies property in params:
import http from "k6/http";
export default function () {
const res = http.get("https://api.example.com/dashboard", {
cookies: {
session_id: "abc123",
},
});
}An important point that often trips people up: the cookie jar is per-VU and not shared between VUs — each VU has its own session, exactly like different browser users. The simulation is more accurate because of this, and it becomes the foundation of the session handling chapter in episode 6. Because of this, make sure the login script runs inside the default function: cookies set in the first iteration will be reused in subsequent iterations of the same VU.
A request that sends the same data on every iteration is an unrealistic simulation — real users don't log in with the same username thousands of times. Parameterization is the technique of replacing static values with values that change, usually taken from a data array. The most basic example, with data from inside the script:
import http from "k6/http";
import { check } from "k6";
const users = [
{ username: "arif", email: "arif@example.com" },
{ username: "dewi", email: "dewi@example.com" },
{ username: "bima", email: "bima@example.com" },
];
export default function () {
const user = users[Math.floor(Math.random() * users.length)];
const payload = JSON.stringify({
username: user.username,
email: user.email,
});
const res = http.post("https://api.example.com/register", payload, {
headers: { "Content-Type": "application/json" },
});
check(res, {
"register 201": (r) => r.status === 201,
"username echoed": (r) => r.json().username === user.username,
});
}Note the users[Math.floor(Math.random() * users.length)] pattern — picking one element at random from the array on each iteration. For large-scale load tests, data from CSV or JSON files is read in the init phase and distributed to VUs — the full pattern will be covered in episode 9 (data-driven testing).
When sending data to an API, two things must always be aligned: the body and the Content-Type header. The JSON body is produced with JSON.stringify from a JavaScript object; Content-Type: application/json tells the server the body is JSON, not form-urlencoded.
import http from "k6/http";
import { check } from "k6";
export default function () {
const payload = JSON.stringify({
title: "Belajar k6",
author: "Arman Dwi Pangestu",
tags: ["devops", "testing"],
});
const res = http.post("https://api.example.com/posts", payload, {
headers: { "Content-Type": "application/json" },
});
check(res, {
"post created": (r) => r.status === 201,
"title echoed": (r) => r.json().title === "Belajar k6",
"id returned": (r) => r.json().id !== undefined,
});
}r.json() parses the response body into a JavaScript object, so even nested fields can be accessed directly — for example r.json().customer.name. The classic mistake: forgetting the Content-Type header, so the server receives the body as application/x-www-form-urlencoded and JSON parsing fails — the symptom is a 400 or 422 status even though the body is correct. Always align body and content-type.
Now let's tie everything together into one complete scenario that represents professional HTTP API load testing: functional validation (is the response correct?) AND performance validation (is the response fast enough?) in a single test. Note that the performance assertion is written as a check — so it is recorded as part of the results, not stopping execution.
import http from "k6/http";
import { check } from "k6";
export const options = {
vus: 10,
duration: "30s",
thresholds: {
http_req_duration: ["p(95)<500"],
},
};
export default function () {
const payload = JSON.stringify({ email: "user@example.com" });
const res = http.post("https://api.example.com/login", payload, {
headers: { "Content-Type": "application/json" },
});
check(res, {
"login status 200": (r) => r.status === 200,
"token returned": (r) => typeof r.json().token === "string",
"response under 800ms": (r) => r.timings.duration < 800,
});
}This script loads 10 VUs for 30 seconds. For each login: it validates the HTTP status, the presence of a token in the response, and the request duration under 800 milliseconds. Meanwhile, the threshold enforces the aggregate rule — 95% of all requests must finish under 500 milliseconds — and if violated, k6 exits with exit code 99. These are the two layers of quality control: per-request (check) and aggregate (threshold).
Important
Understand the division of labor: check validates the correctness of each response, threshold enforces the aggregate performance standard. Using a threshold only for "status 200" is a misuse — thresholds measure time and ratio metrics, checks evaluate response content. Design both with clear roles from the start.
Content-Type: application/json. The server receives the body as form-urlencoded and rejects it with 400/422. Always keep them aligned.r.body.includes("ok") is prone to false positives. r.json().field with a clear structure is more reliable.In this episode 4 you've mastered basic HTTP API load testing:
headers object in params, the Authorization: Bearer ... pattern, and inserting tokens via template literals.JSON.stringify, the Content-Type header, and reading nested responses with r.json().check() for per-request assertions (functional and performance), thresholds for aggregate standards with exit code 99.You now write realistic, structured API load tests. In episode 5 we explore dynamic data & cookies & session handling deeper: creating dynamic data that varies per iteration, managing the cookie jar explicitly, and handling scenarios that need cross-request sessions. See you in episode 5!