Skip to main content
Jul 13, 2026k6 oauth2 load test

By Performate

OAuth2 and API Key Auth in k6: Token Refresh Patterns That Survive Concurrency

OAuth2 and API keys in k6 under concurrency: token refresh, client credentials, env secrets—k6 HTTP auth docs and realistic load patterns.

Static bearer tokens in k6 scripts make every run look healthy—until production OAuth refresh storms, rate-limited token endpoints, and clock skew produce 401 spikes under load. Real clients renew tokens; your load test should too.

OAuth2 and API key auth in k6 is not "paste the JWT from DevTools." It is modeling token lifecycle under concurrency: client credentials for service accounts, refresh before expiry, and separate scenarios when API keys hit RPM limits. In this guide you will learn why auth collapses under VUs, how to structure setup and refresh in k6, and which patterns belong in CI smoke vs full load—not a hard-coded secret in git.

Why auth fails under load—not at single-request scale

Functional tests fetch one token and reuse it forever. Concurrent k6 exposes different failures:

  • Token endpoint rate limits throttle refresh when many VUs renew simultaneously.
  • Short-lived access tokens expire mid-scenario—401 bursts if refresh is per-iteration but uncached.
  • Client credentials storms stampede the IdP when every VU calls /token independently.
  • API key RPM caps return 429 while business routes still look "fine" at low rate.
  • Clock skew between k6 runners and auth server causes premature "expired" errors.

Think of tokens like conference badges: one badge per attendee works until everyone tries to re-print badges at the same kiosk at session change.

Reference OAuth 2.0 overview and k6 environment variables for secrets. Pair with k6 secrets in test environments and managing env vars.

Practical k6 implementation: shared refresh with per-VU cache

Use setup() for one client-credentials fetch in smoke, or module-level refresh with mutex-like logic for longer runs—fetch token once per VU iteration batch, not once globally forever.

Example script (illustrative—not a production-ready test). Uses fictional IdP URLs; adapt grant type to your provider.

What this example demonstrates:

  • Client credentials in setup: one token fetch before scenarios when acceptable for service-to-service load.
  • Per-VU token cache with TTL check: refresh when expires_in window is near—mirrors SDK behavior.
  • Separate scenario for API key route: tags auth:api_key vs auth:oauth for triage.
  • Thresholds on auth failures: http_req_failed{route:token} catches IdP collapse early.
import http from 'k6/http';
import { check, sleep } from 'k6';

const BASE = __ENV.API_BASE || 'https://staging.example.com';
const TOKEN_URL = __ENV.TOKEN_URL || 'https://idp.example.com/oauth/token';

const tokenCache = {};

function getAccessToken(vuId) {
  const now = Date.now();
  const cached = tokenCache[vuId];
  if (cached && cached.expiresAt > now + 5000) {
    return cached.accessToken;
  }

  const res = http.post(
    TOKEN_URL,
    {
      grant_type: 'client_credentials',
      client_id: __ENV.CLIENT_ID,
      client_secret: __ENV.CLIENT_SECRET,
      scope: 'api.read',
    },
    { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, tags: { route: 'token' } },
  );

  check(res, { 'token 200': (r) => r.status === 200 });

  const body = res.json();
  tokenCache[vuId] = {
    accessToken: body.access_token,
    expiresAt: now + (body.expires_in || 3600) * 1000,
  };
  return tokenCache[vuId].accessToken;
}

export const options = {
  scenarios: {
    oauth_api: {
      executor: 'constant-arrival-rate',
      rate: Number(__ENV.API_RPS || 30),
      timeUnit: '1s',
      duration: '5m',
      preAllocatedVUs: 15,
      maxVUs: 60,
      exec: 'callWithOAuth',
      tags: { auth: 'oauth' },
    },
    api_key_route: {
      executor: 'constant-arrival-rate',
      rate: Number(__ENV.KEY_RPS || 10),
      timeUnit: '1s',
      duration: '5m',
      preAllocatedVUs: 5,
      maxVUs: 20,
      exec: 'callWithApiKey',
      tags: { auth: 'api_key' },
    },
  },
  thresholds: {
    'http_req_failed{route:token}': ['rate<0.02'],
    'http_req_failed{auth:oauth}': ['rate<0.01'],
    'http_req_duration{auth:oauth}': ['p(95)<600'],
  },
};

export function callWithOAuth() {
  const token = getAccessToken(__VU);
  const res = http.get(`${BASE}/v1/orders`, {
    headers: { Authorization: `Bearer ${token}` },
    tags: { route: 'orders', auth: 'oauth' },
  });
  check(res, { 'orders 2xx': (r) => r.status >= 200 && r.status < 300 });
  sleep(0.2);
}

export function callWithApiKey() {
  const res = http.get(`${BASE}/v1/metrics`, {
    headers: { 'X-API-Key': __ENV.API_KEY },
    tags: { route: 'metrics', auth: 'api_key' },
  });
  check(res, { 'metrics not 429': (r) => r.status !== 429 });
  sleep(0.3);
}

Patterns that work

  • Stagger token refresh: jitter sleep before refresh when many VUs share expiry windows.
  • Dedicated low-rate token scenario to stress IdP separately from business APIs.
  • Never commit secretsCLIENT_SECRET and API_KEY only via CI/env (k6 secrets guide).
  • Tag auth path for dashboards—401 on business routes vs token route differs (k6 observability tags).

Anti-patterns to avoid

  • One global token for 500 VUs when provider issues per-client limits.
  • Refresh on every iteration regardless of TTL—creates artificial IdP load.
  • Ignoring 429 on API key routes—product may hit limits before servers saturate.

Pro tip (example command):

k6 run oauth-load.js -e CLIENT_ID=$ID -e CLIENT_SECRET=$SECRET -e API_KEY=$KEY

What this command demonstrates: secrets injected at runtime—the same pattern CI and local staging should use.

Decision framework: static token vs refresh vs dual auth

SituationRecommended action
Short CI smoke, long-lived staging tokenSetup fetch once; rotate token out-of-band
Production-like OAuth with TTL < 15mPer-VU cache + refresh before expiry
API key on partner endpointsSeparate scenario; monitor 429 thresholds
IdP rate limit knownCap VUs; add jitter; optional dedicated token scenario
Mixed OAuth + API key clientsParallel scenarios with auth:* tags

Use static setup token if smoke is <2m and IdP owner approves shared service token.

Use per-VU refresh if access tokens expire during realistic test duration.

Use dual scenarios if prod traffic mixes OAuth user context and API key integrations.

Observability, documentation, and next steps

Auth load tests fail silently when 401s are treated as generic errors. Before scaling:

  • Document grant type, scopes, token TTL, and IdP rate limits in runbook.
  • Alert separately on route:token failures vs business route 401 rates.
  • Correlate k6 auth:* tags with IdP metrics during staging windows.
  • Automate OAuth smoke in CI with rotated secrets store—not committed tokens (CI/CD load testing).
  • Archive scenario and secret injection pattern per environment (staging vs load lab).

How Performate simplifies OAuth2 load testing

Copy-pasting bearer tokens from browser devtools does not scale across scenarios. Below is a concrete workflow example for orders (OAuth) and metrics (API key).

Example: dual auth scenarios with env secrets

  1. Import Postman collection with OAuth and API-key requests; store env var names in collection. Problem solved: routes defined once; secrets stay external.
  2. Configure oauth_api scenario at 30 req/s with pre-request note linking to token URL. Problem solved: visual map of auth vs business traffic.
  3. Add api_key_route scenario at 10 req/s with X-API-Key header bound to env. Problem solved: RPM limit testing without script fork.
  4. Apply tags auth:oauth and auth:api_key for filtered reports. Problem solved: same tag model as k6 example.
  5. Run against staging with secrets from desktop env file (never committed). Problem solved: engineers iterate without editing script for each token rotation.
  6. Export k6 script for CI with secret store injection matching local env var names. Problem solved: federated execution without auth drift.

That workflow maps to the cta: runnable scenarios, thresholds, and reports without glue-code days.

Closing takeaway

OAuth in k6 is a lifecycle problem: refresh, rate limits, and API keys need separate scenarios and tags—not a static JWT from last Tuesday.

Run this week's load test with token refresh enabled—and watch whether the IdP or your API hits 429 first when VUs scale.

Try Performate free | Book a demo | k6 environment variables

Ready to optimize your API performance?

Use Performate to turn this playbook into runnable k6 scenarios, thresholds, and shareable reports without losing days to glue code.

← Back to all posts