Bringing real authentication flows into your load test: choosing between the OAuth2, JWT, API key, or cookie-based session approaches, logging in to obtain a token, using it as a bearer token on subsequent requests, up to refreshing tokens mid-way through long-running scenarios.

In episode 10 you already broke through the networking layer — TLS, redirects, even WebSocket. But most APIs worth testing don't open their doors directly: there's an authentication gate in front. If your load test only touches public endpoints, you're measuring the back entrance, not the journey real users actually take. Users open the app, log in, then use the features — and the login and token validation load is part of their experience.
This episode answers the question that has been hanging around: how do you authenticate inside k6? We'll choose the right mechanism (OAuth2, JWT, API key, cookie session), mimic the login flow through to using the token, then solve the problem that most often makes load tests lie: tokens that expire mid-way through long scenarios.
There are two common mistakes: avoiding protected endpoints, or faking tokens carelessly. Both test a different application than the one real users use. Endpoints that require authentication usually carry auth middleware, a session store, and database lookups — code that contributes to latency. Without loading this flow, your test results are too good and useless for capacity planning.
On the other hand, don't copy a token from the browser and drop it into the script as a constant either. That token will expire, and more importantly: you're not measuring a real login flow. The key to balance is: log in the right way, as often as necessary, and no more.
Before writing code, identify the mechanism your application uses:
| Mechanism | How it's sent | When commonly used | Main concern in k6 |
|---|---|---|---|
| API key | X-API-Key header | service-to-service, simple integrations | store in __ENV, never hardcode |
| JWT bearer | Authorization: Bearer header | SPAs and modern APIs | tokens are short-lived, need refresh |
| OAuth2 | token flow, usually POST /token | enterprise applications, SSO | tokens have expires_in, don't over-login |
| Cookie session | session cookie | traditional web apps | cookies are sent automatically if using a jar |
The rule is simple: log in at least as often as real users do. If the token lasts one hour and the test scenario runs 30 minutes, a single login in setup() already represents the user. If the token lasts 5 minutes, you must mimic the refresh process mid-scenario — the part that is often forgotten yet is the most realistic.
k6 executes setup() once before VUs start running, and its result is passed to the iteration function. This is the best place to log in: one login call, then the token is shared with all VUs. Great for tokens with a reasonably long lifetime:
import http from "k6/http";
import { check } from "k6";
const BASE_URL = __ENV.BASE_URL || "https://api.example.com";
export function setup() {
const loginRes = http.post(`${BASE_URL}/oauth/token`, {
grant_type: "password",
username: __ENV.TEST_USER,
password: __ENV.TEST_PASSWORD,
});
check(loginRes, { "login succeeded": (r) => r.status === 200 });
return loginRes.json();
}
export default function (auth) {
const params = {
headers: {
Authorization: `Bearer ${auth.access_token}`,
},
};
const res = http.get(`${BASE_URL}/me`, params);
check(res, { "profile is accessible": (r) => r.status === 200 });
}Notice the two-layer pattern you must not forget:
check(loginRes, ...) — if login fails, the entire test measures error responses, not the application. This check is your early warning alarm.return loginRes.json() — the value returned by setup() is automatically available as an argument to the default function. All VUs share this object without repeating the login.One login for all VUs also means one test account. With tens of thousands of requests within a few minutes, that account would generate unrealistic login load if every VU logged in on its own. This isn't a bug — it's a design decision: how often do real users log in during that period?
If the application uses an API key, the pattern is shorter — no setup() needed:
import http from "k6/http";
import { check } from "k6";
const params = {
headers: {
"X-API-Key": __ENV.API_KEY,
},
};
export default function () {
const res = http.get("https://api.example.com/v1/orders", params);
check(res, { "orders are authenticated": (r) => r.status === 200 });
}The key value here is __ENV.API_KEY — we'll break down the security reasoning in depth in episode 12. For now, remember the rule: real credentials never go into script files.
Traditional web applications usually use cookie-based sessions. k6 has a cookie jar per VU that automatically stores and sends cookies: log in once at the start of the iteration, and subsequent requests carry the cookie without needing manual header setup:
import http from "k6/http";
import { check } from "k6";
export default function () {
const loginRes = http.post("https://app.example.com/login", {
username: __ENV.TEST_USER,
password: __ENV.TEST_PASSWORD,
});
check(loginRes, { "session login succeeded": (r) => r.status === 200 });
const profilRes = http.get("https://app.example.com/me");
check(profilRes, { "cookie is sent automatically": (r) => r.status === 200 });
}Because every VU has its own cookie jar, sessions between VUs are isolated — this is important: one VU must not inherit another VU's session, as that would skew the metrics. The jar is also reset at the end of each iteration, so every iteration represents a newly logged-in user. If your application extends sessions with re-sent cookies (rolling sessions), consider noCookiesReset for scenarios that mimic long-online users.
This is the problem that most often makes load tests produce misleading data: the access token (e.g., JWT) expires within minutes, but the scenario runs for half an hour. Without handling it, 401 responses poison your metrics — and you'd conclude "the app errors with 401 at minute 25" when that's token behavior, not an application bug.
The correct pattern: detect 401, refresh the token, retry the request. Since the token is shared across VUs, the refresh happens inside the iteration that finds the 401:
import http from "k6/http";
import { check, sleep } from "k6";
const BASE_URL = __ENV.BASE_URL || "https://api.example.com";
export function setup() {
const loginRes = http.post(`${BASE_URL}/login`, {
username: __ENV.TEST_USER,
password: __ENV.TEST_PASSWORD,
});
return loginRes.json();
}
export default function (auth) {
let token = auth.access_token;
let refreshToken = auth.refresh_token;
for (let i = 0; i < 10; i++) {
const res = http.get(`${BASE_URL}/dashboard`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 401) {
const refreshed = http.post(`${BASE_URL}/refresh`, {
refresh_token: refreshToken,
});
check(refreshed, { "refresh token accepted": (r) => r.status === 200 });
const body = refreshed.json();
token = body.access_token;
refreshToken = body.refresh_token || refreshToken;
} else {
check(res, { "dashboard OK": (r) => r.status === 200 });
}
sleep(5);
}
}With this pattern, the load test measures real behavior: the token does expire mid-flight, the application must refresh, and that's part of the user experience. If the refresh server is slow or fails under load, you'll see it — and that's exactly a valuable finding. Also remember to measure how often 401s appear; if they appear far more often than they should, that's a signal that token configuration (lifetime, clock skew, or server time) needs investigation.
A good load test doesn't only measure the success path. Protected endpoints should also be tested from the negative side:
check expected statuses explicitly, and don't let code use expired tokens without verification.The best pattern: create iterations that verify authorization as part of the flow, with a specific tag (e.g., endpoint: "me"), so that later in the dashboard you can compare latency and error rate per protected endpoint. Testing both the correct and the wrong access paths is how you ensure the test measures not just speed, but also that security still works under load.
setup() for long-lived tokens.__ENV or a data-driven test (episode 8).In this episode 11 you've been able to bring real authentication into your load test: choosing the right mechanism among API key, JWT bearer, OAuth2, and cookie sessions, using setup() to log in once and share the token across all VUs, letting the cookie jar handle sessions automatically, applying the refresh-token pattern mid-way through long scenarios, and validating both correct and incorrect access paths.
Key points to take away:
setup.check on login prevents the test from measuring error responses.Starting with the next episode, we're dealing with something more serious. Every token and credential you now hold in your script is an asset that must be protected. In episode 12 we'll cover Testing Security & Best Practices — how to make sure your tests don't break the application's security rules, handle secrets correctly, and respect production environments. See you in episode 12!