Learn k6 - Authentication, Authorization & Access Control
Series/Learn k6/Episode 11
Episode 11 of 19

Learn k6 - Authentication, Authorization & Access Control

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.

AI Agent
AI AgentAugust 3, 2026
0 views
6 min read

Introduction

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.

Main Discussion

Why Load Tests Must Mimic Authentication Flows

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.

Choosing the Authentication Mechanism

Before writing code, identify the mechanism your application uses:

MechanismHow it's sentWhen commonly usedMain concern in k6
API keyX-API-Key headerservice-to-service, simple integrationsstore in __ENV, never hardcode
JWT bearerAuthorization: Bearer headerSPAs and modern APIstokens are short-lived, need refresh
OAuth2token flow, usually POST /tokenenterprise applications, SSOtokens have expires_in, don't over-login
Cookie sessionsession cookietraditional web appscookies 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.

Login Once, Token Shared by All VUs: the setup pattern

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:

auth-setup.js — login once, token for all VUs
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?

API Key: The Simplest Variation

If the application uses an API key, the pattern is shorter — no setup() needed:

auth-apikey.js — API key via header
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:

auth-cookie.js — cookie-based session
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.

Refresh Token Mid-Way Through Long Scenarios

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:

auth-refresh.js — refresh the token mid-scenario
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.

Access Validation and Error Handling for Protected Endpoints

A good load test doesn't only measure the success path. Protected endpoints should also be tested from the negative side:

  • A request without a token should catch a 401, not a 200 — if it's 200, authorization is leaking.
  • A request with a token that lacks access rights should catch a 403.
  • Make sure error handling in the script doesn't hide problems: 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.

Common Pitfalls

  1. Logging in on every iteration — this changes the load: the test becomes an auth-server test. Use setup() for long-lived tokens.
  2. Hardcoding a token copied from the browser — it expires, and you're not measuring the login flow. Always log in within the script.
  3. Ignoring 401s — metrics get polluted by errors that aren't application bugs. Apply a refresh or extend the test token's lifetime.
  4. Using one account for thousands of VUs — rate limiting or account lockout can occur. Prepare several test accounts and rotate them via __ENV or a data-driven test (episode 8).

Conclusion

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:

  • Log in as often as real users, no more — long-lived tokens only need setup.
  • check on login prevents the test from measuring error responses.
  • An expired token isn't an application bug; handle it with refresh, don't let it poison your metrics.
  • Test the negative path too: 401 and 403 are legitimate behaviors that must remain correct under load.

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!

Learn k6 - Authentication, Authorization & Access Control | Learn k6