Firsty BuildersGuidesOAuth2 client credentials in production: what most tutorials get wrong
Integration

OAuth2 client credentials in production: what most tutorials get wrong

Token caching, refresh strategy, and the security mistakes we see in production integrations every week.

GTGauthier ThierensMay 24, 2026· 9 min read
Integration

OAuth2 client credentials in production: what most tutorials get wrong

Most OAuth2 tutorials end at "now you have a token." That's where the real problems start.

We see the same five mistakes in production integrations across our customers. None of them are obvious from the spec. All of them break things at scale.

This post is about what actually goes wrong with the client credentials grant in production, and how to handle it correctly.

The basic flow (the part everyone gets right)

Client credentials is the OAuth2 grant type for server-to-server authentication. You exchange your client ID and secret for an access token. You include that token as a Bearer header on subsequent API calls.

javascript
const response = await axios.post('https://api.example.com/auth/token',
  new URLSearchParams({
    grant_type: 'client_credentials',
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
  }),
  { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);

const token = response.data.access_token;

This works. It's also where most tutorials stop. In a real production system, this naive code will eventually break in five distinct ways.

Mistake 1: Fetching a new token per request

Try it yourself

Free sandbox. Real Tier-1 carriers. 60 seconds from signup to credentials.

Get started →

The most common pattern we see in code reviews is one token fetch per API call:

javascript
async function provisionEsim() {
  const token = await getToken();  // every call!
  return axios.post('/esims', {}, { headers: { Authorization: `Bearer ${token}` } });
}

Tokens are valid for 24 hours. Fetching a new one for each call wastes time, burns through rate limits on your auth endpoint, and creates a hard dependency chain (auth must succeed before any business call).

The fix: cache the token with its expiry timestamp.

javascript
let cachedToken = null;
let tokenExpiresAt = 0;

async function getToken() {
  const now = Date.now();
  if (cachedToken && now < tokenExpiresAt) {
    return cachedToken;
  }

  const response = await axios.post(/* ... */);
  cachedToken = response.data.access_token;
  tokenExpiresAt = now + (response.data.expires_in - 300) * 1000;
  return cachedToken;
}

The 5-minute buffer matters. Without it, you'll occasionally hit "token just expired" errors mid-request, and your retry logic will trigger a fresh fetch under load. Better to refresh proactively.

Mistake 2: Caching the token in the wrong place

In-memory caching works for a single Node process. It breaks the moment you horizontally scale.

We've seen teams cache the token in:

  • A global variable (broken across server restarts)
  • A file on disk (broken across multiple instances)
  • A database row (works, but adds round trips)
  • Redis or similar (correct)

For a single-instance app or a low-volume integration, in-memory is fine. Once you're behind a load balancer or running serverless, use a shared cache. Tokens are not sensitive in the way passwords are, but they should still be encrypted at rest and never logged.

Mistake 3: Race conditions on token refresh

You have a cached token. It expires. Five concurrent requests notice this and each try to refresh.

Now you have five token fetches happening in parallel. The auth server returns a token for each. Four are discarded. You've also potentially rate-limited yourself on the auth endpoint.

The fix: lock token refresh.

javascript
let refreshPromise = null;

async function getToken() {
  if (cachedToken && Date.now() < tokenExpiresAt) {
    return cachedToken;
  }

  if (refreshPromise) {
    return refreshPromise;
  }

  refreshPromise = fetchNewToken().finally(() => {
    refreshPromise = null;
  });

  return refreshPromise;
}

This is the "promise singleflight" pattern. Only one refresh runs at a time. All concurrent callers await the same promise.

Mistake 4: Treating auth errors as transient

When your API call returns 401, what does your code do?

The wrong answer: retry the same call with the same token.

The right answer: refresh the token, retry once, give up if it fails again.

javascript
async function apiCall(endpoint, body, retried = false) {
  const token = await getToken();
  try {
    return await axios.post(endpoint, body, {
      headers: { Authorization: `Bearer ${token}` }
    });
  } catch (err) {
    if (err.response?.status === 401 && !retried) {
      cachedToken = null;
      return apiCall(endpoint, body, true);
    }
    throw err;
  }
}

Without the

retried
flag, a permanently bad credential creates an infinite loop. With it, you get one clean retry and then a real error.

Mistake 5: Logging the token

You'll be tempted to log API responses for debugging. Don't log the full token. Don't log it in error messages. Don't include it in Sentry breadcrumbs.

Token leakage is a real attack vector. If an attacker pulls a valid bearer token from your logs, they can impersonate your application until the token expires. Even 24-hour tokens are valuable in that window.

If you must log auth-related debugging info, log only the token's expiry timestamp and a prefix (first 8 characters) for correlation. Never the full string.

A correct implementation

Pulling it together:

javascript
const Redis = require('ioredis');
const redis = new Redis();
const REFRESH_BUFFER_SECONDS = 300;
const TOKEN_KEY = 'firsty:token';

let refreshPromise = null;

async function getToken() {
  const cached = await redis.get(TOKEN_KEY);
  if (cached) {
    const { token, expiresAt } = JSON.parse(cached);
    if (Date.now() < expiresAt) return token;
  }

  if (refreshPromise) return refreshPromise;

  refreshPromise = (async () => {
    const response = await axios.post(
      'https://connect.test.firsty.app/api/v3/auth/token',
      new URLSearchParams({
        grant_type: 'client_credentials',
        client_id: process.env.FIRSTY_CLIENT_ID,
        client_secret: process.env.FIRSTY_CLIENT_SECRET,
      }),
      { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
    );

    const token = response.data.access_token;
    const expiresAt = Date.now() + (response.data.expires_in - REFRESH_BUFFER_SECONDS) * 1000;

    await redis.set(TOKEN_KEY, JSON.stringify({ token, expiresAt }),
      'EX', response.data.expires_in - REFRESH_BUFFER_SECONDS);

    return token;
  })().finally(() => { refreshPromise = null; });

  return refreshPromise;
}

This handles all five issues: cached in Redis (works across instances), respects expiry with buffer, locks refresh, and never logs the token.

What to test

Before you ship, test:

  1. Two simultaneous requests when the cache is empty (verify only one auth call happens)
  2. A request after manually clearing the cache (verify refresh works)
  3. A 401 response from the API (verify retry triggers once)
  4. A response from the auth server with a malformed body (verify graceful failure)

If your test suite doesn't cover these, you'll find them in production.

Bottom line

OAuth2 client credentials is simple. Using it correctly at scale is not. The five mistakes in this post account for the vast majority of auth-related production incidents we see across our customers.

If you're integrating Firsty's API, the sandbox uses the exact same auth flow as production. Test these patterns in sandbox before you go live.

ShareLinkedInX
New guides every Tuesday. No marketing nonsense.