Skip to main content

Configure OAuth2 Load Testing for APIs

Configure OAuth2 Load Testing for APIs

Key Takeaways

  • In k6, fetch OAuth2 tokens with plain http.post calls: once in setup() for a quick smoke test, or per virtual user (VU) with an expiry check for a real load test.
  • A token fetched once in setup() expires mid-run. In the local run below it lasted 6 seconds, and 690 of 990 API requests (all of them after expiry) came back 401.
  • Tag token requests with their own name and a custom metric, so the token endpoint’s numbers never mix into the API’s percentiles.
  • Watch the token endpoint as its own system: request rate, error rate and latency, and a refresh stampede when many VUs share one expiry time.

To configure OAuth2 for a k6 load test, request a token from the authorization server with the client credentials grant, send it as an Authorization: Bearer header, and decide how often each VU fetches a new one. This post is the k6 configuration. The concepts (which grant to use, what to measure, credential hygiene) are in the guide to performance testing APIs with OAuth2, and the click-by-click JMeter build is in how to test OAuth secured APIs and apps using JMeter. Both are linked rather than repeated here.

How do I get an OAuth2 token in a k6 script?

Send a POST to the token endpoint with grant_type=client_credentials, authenticated as the client. RFC 6749 section 2.3.1 says an authorization server must support HTTP Basic authentication for clients that hold a client password, and may also accept client_id and client_secret in the request body, so Basic is the safer default. The client credentials grant has no user and no browser, which is why it suits load tests of service-to-service APIs. RFC 6749 section 4.4.3 says a refresh token should not be included in that response: when the access token expires, the client asks again.

For APIs behind a user login, the authorization code flow with PKCE needs a browser redirect that k6 does not drive. Generate the tokens ahead of the run, put them in a data file, and read them as shown below. RFC 9700 (January 2025) says the resource owner password grant “MUST NOT be used” and that clients “SHOULD NOT” use the implicit grant, so do not build a test on either.

How do I fetch the token once in setup()?

k6 runs setup() once per test, after the init stage and before any VU code, and passes its return value to every VU (grafana.com/docs/k6, test lifecycle, October 2026). That makes it the shortest way to get a token into a script:

export function setup() {
  const res = http.post(`${BASE}/token`, { grant_type: 'client_credentials' }, {
    headers: { Authorization: `Basic ${encoding.b64encode(`${id}:${secret}`)}` },
  });
  return { value: res.json().access_token };
}

export default function (token) {
  http.get(`${BASE}/api/items`, { headers: { Authorization: `Bearer ${token.value}` } });
}

This is fine for a smoke test. For a load test it has two weaknesses. Every VU is the same caller, so rate limits, caches and per-user work behave differently than in production. And the token lives only as long as its expires_in, which RFC 6749 describes as the lifetime in seconds from the time the response was generated.

To see the second weakness, I ran this pattern against a small local mock authorization server (a throwaway Python script, not a real identity provider) that issues tokens valid for 6 seconds. Ten VUs ran for 20 seconds. The mock counted 300 requests accepted and 690 rejected with 401, k6 reported 69.6% failed requests, and the http_req_failed threshold was crossed (k6 v1.7.1, 2026-10-23). The API was fine. The test was measuring an expired token for two thirds of its life.

How do I give every VU its own token and refresh it before it expires?

Keep the token in a variable at module level. In k6, the script’s top-level code runs once per VU, so each VU holds its own copy and can check it on every iteration. Fetch a new token when the current one is close to expiry, with a little random jitter so the VUs do not all return to the token endpoint in the same moment. Credentials come from a CSV file through SharedArray, which keeps one copy in memory for all VUs.

Here is the full script I ran. The mock server and clients.csv are not shown; the CSV has a client_id,client_secret header and one row per test client.

import http from 'k6/http';
import { check, sleep } from 'k6';
import { Trend, Counter } from 'k6/metrics';
import { SharedArray } from 'k6/data';
import encoding from 'k6/encoding';

const BASE = __ENV.BASE_URL || 'http://127.0.0.1:8099';
const MODE = __ENV.MODE || 'per-vu'; // 'shared' or 'per-vu'
const REFRESH_MARGIN_S = Number(__ENV.REFRESH_MARGIN_S || 2); // refresh this long before expiry

const clients = new SharedArray('clients', () =>
  open('./clients.csv').split('\n').slice(1).filter(Boolean).map((l) => {
    const [client_id, client_secret] = l.split(',');
    return { client_id, client_secret };
  })
);

const tokenDuration = new Trend('token_request_duration', true);
const tokenRequests = new Counter('token_requests');

function fetchToken(client) {
  const res = http.post(
    `${BASE}/token`,
    { grant_type: 'client_credentials' },
    {
      headers: { Authorization: `Basic ${encoding.b64encode(`${client.client_id}:${client.client_secret}`)}` },
      tags: { name: 'token' }, // keep token calls out of http_req_duration{name:api}
    }
  );
  tokenDuration.add(res.timings.duration);
  tokenRequests.add(1);
  check(res, { 'token 200': (r) => r.status === 200 });
  const body = res.json();
  return { value: body.access_token, expiresAt: Date.now() + body.expires_in * 1000 };
}

export const options = {
  scenarios: {
    api: { executor: 'constant-vus', vus: 10, duration: __ENV.DURATION || '20s' },
  },
  thresholds: {
    'http_req_duration{name:api}': ['p(95)<200'],
    'http_req_failed{name:api}': ['rate<0.01'],
    token_request_duration: ['p(95)<500'],
  },
};

export function setup() {
  // one token for every VU: simplest, least realistic
  return MODE === 'shared' ? fetchToken(clients[0]) : null;
}

let mine = null; // module scope is per VU in k6
export default function (shared) {
  let tok = shared;
  if (MODE === 'per-vu') {
    const client = clients[(__VU - 1) % clients.length];
    if (!mine || Date.now() > mine.expiresAt - REFRESH_MARGIN_S * 1000 - Math.random() * 1000) {
      mine = fetchToken(client);
    }
    tok = mine;
  }
  const res = http.get(`${BASE}/api/items`, {
    headers: { Authorization: `Bearer ${tok.value}` },
    tags: { name: 'api' },
  });
  check(res, { 'api 200': (r) => r.status === 200 });
  sleep(0.2);
}

Ten VUs, 20 seconds, 6-second tokens, refresh margin 2 seconds: the mock server counted 60 token requests and 1,050 accepted API requests, with no 401 and no failed check. The summary’s token_request_duration had a p95 of about 26 ms on this local setup, and the http_req_duration{name:api} threshold held. With a deliberately failing threshold (MODE=shared) k6 exited with code 99; the passing run exited with 0. These are numbers from a laptop and a mock, useful to show the mechanics and not as a benchmark of any real identity provider.

Three details in the script matter:

  • tags: { name: 'token' } and a Trend. The tag lets the thresholds target http_req_duration{name:api} so token calls cannot pollute the API’s percentiles, and the custom token_request_duration metric gives the token endpoint its own line in the summary. The percentiles article explains why you read both as percentiles.
  • The jitter. Math.random() * 1000 spreads the refresh over a second here. For a real token lifetime of an hour, spread it over minutes. Without it, 1,000 VUs started together all need a new token in the same second.
  • One client per VU, reused across VUs. (__VU - 1) % clients.length maps VUs onto the file’s rows. If your authorization server limits concurrent tokens per client, use one client per VU or per small group.

How do I use a pool of pre-generated tokens?

When the real flow is authorization code with PKCE, create the tokens before the test with whatever your identity provider allows for test users, save them in a CSV or JSON file, and load them with SharedArray the same way as the client list above. Each VU picks tokens[__VU % tokens.length]. If the file’s access tokens are shorter-lived than the run, add each user’s refresh token as a second column and have the script call the token endpoint with grant_type=refresh_token before expiry. RFC 9700 requires refresh tokens for public clients to be sender-constrained or rotated, so a rotating server returns a new refresh token each time: write the new one back into the VU’s variable, because the old one stops working.

What should I watch at the token endpoint?

Treat the authorization server as a second system under test, even if you only meant to test the API.

SignalWhat it tells you
Token requests per secondWhether your refresh logic produces a steady trickle or a stampede at one expiry time
token_request_duration percentilesIssuer latency under load, separate from the API’s
4xx and 429 on the token endpointRate limiting or client lockout, which means the test is overwhelming the issuer and not the API
401 versus 403 on the API401 is usually a missing, malformed or expired token; 403 means a valid token without the needed scope
API latency right after a token refreshWhether the first request with a new token is slower, for example because the gateway fetches signing keys or introspects the token

Many identity providers rate-limit the token endpoint, and some do it per client. If your test trips that limit, you are measuring the provider’s protection, so agree on a test client and limit with whoever runs it. For limits on the API side, see what trips API rate limiting.

How do I run this script on LoadFocus?

LoadFocus runs uploaded k6 scripts from 25 cloud regions, and its k6 page lists environment variables and data file parameterization among the supported features (loadfocus.com/k6-load-testing, October 2026). The docs say it accepts .js, .ts, .csv and .json uploads and validates the export default function and the k6/* imports (loadfocus.com/docs, “How to Run a New k6 Load Test”). So the script above is a candidate to upload with its clients.csv; change BASE_URL to your real endpoint and keep real secrets out of the uploaded file.

Two documented behaviours matter here. First, a test with enough VUs runs on more than one k6 engine, and every engine receives the same uploaded files, so two engines can read the same CSV row (uploading k6 scripts and data files). That is harmless for client credentials that may be shared, and a problem for single-use credentials. Second, I did not test this script on LoadFocus, and the pages I read do not describe OAuth-specific settings for k6 tests, so the token handling stays in your script. After launch, the API monitoring page lists OAuth bearer tokens and a setup script that fetches a token before the request, which suits the recurring check that follows the load test. The k6 load testing page covers running k6 scripts in the cloud, and running k6 at scale compares the ways to do it.

What mistakes make OAuth2 load test results misleading?

  • Token fetched once in setup() for a long test. The second half measures 401 responses.
  • No jitter on refresh. The token endpoint receives a spike that production never sees, or the reverse: the spike you wanted to model never happens.
  • Token requests inside the API’s metrics. Tag them and set thresholds on the tagged series.
  • A secret in the script. Pass it through an environment variable (__ENV) and keep it out of the repository and the results.
  • Reading only the API. Slow or throttled token issuance hides behind a healthy-looking API chart.

For the broader JMeter-versus-k6 trade-off, see k6 vs JMeter.

Frequently Asked Questions

How do I configure OAuth2 in a k6 load test?

Request a token with http.post to the token endpoint using the client credentials grant and HTTP Basic client authentication, then send it as an Authorization: Bearer header on each API request. Fetch it in setup() for a smoke test, or per VU with an expiry check for a longer run.

Does k6 have built-in OAuth2 support?

Its k6/http module sends the requests, but the token handling is code you write: the request, the expiry check and the refresh. The script in this post is about 70 lines. I found no built-in token manager in the k6 documentation pages I read.

Why do my k6 requests start failing with 401 halfway through?

The access token expired. The token response’s expires_in states the lifetime in seconds. Use tokens that outlive the test, or refetch before expiry with some jitter, as in the script above.

Should each virtual user get its own token?

For realistic results, yes, or at least a pool of distinct tokens, so rate limits and caches see distinct callers. Ramp the VUs up gradually so the initial token requests do not become a spike you did not plan.

How do I keep client secrets out of a k6 script?

Read them with __ENV and pass them on the command line or from your CI secret store, use a dedicated test client, and avoid saving responses that contain tokens.

Can I use the password grant to simplify the test?

Avoid it. RFC 9700 says the resource owner password credentials grant “MUST NOT be used”, so a load test is a poor reason to keep it alive.

Sources

Published with PostNext

Chris

Head of Content at LoadFocus

Chris leads content and oversees development across the services this blog covers. The guides and tool comparisons here come out of the same decisions that shape what ships.

Weekly tips right in your inbox.

Don’t miss out on the latest testing tips and tricks, tools and news related to software testing.

Already +20,000 members in the list.

The Load Testing Platform with AI That Explains Your Results

Load test websites and APIs from 25 cloud regions, monitor page speed and uptime, and get AI analysis that explains your results in plain English.

Cloud load testing, website speed and API monitoring in one place.

Hi there! Can I share a cool thing I'm working on with you?

A software testing newsletter that sparks joy.

Our goal is to create helpful content for software developers and testers.

If you're not into it, you can unsubscribe instantly.