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.
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.
javascriptconst 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:
javascriptasync 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.
javascriptlet 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.
javascriptlet 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.
javascriptasync 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
retriedMistake 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:
javascriptconst 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:
- Two simultaneous requests when the cache is empty (verify only one auth call happens)
- A request after manually clearing the cache (verify refresh works)
- A 401 response from the API (verify retry triggers once)
- 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.
Related guides
How to provision your first eSIM via API in 30 minutes
From OAuth token to first eSIM activated, with QR code generation server-side. Real code, real credentials, real eSIM, in about 30 minutes.
The LPA activation code format explained
What the LPA format actually is, how to render QR codes from it correctly, and why some QR codes work on iPhone but not Android.
profileReference vs esimReference vs ICCID: which identifier when?
Three identifiers, three meanings, frequently confused. Here is which one to use where.