TL;DR — the correct token manager shape
const manager = createTokenManager({ store, refresh, skewMs: 60_000 });
const token = await manager.getValidToken(); // always a valid access token, handles refresh
await manager.invalidate(); // force a refresh on next call (e.g. 401 from server)
Internally: fast path (token still valid), single-flight on expiry, rotation-merge on persist, atomic write to store.
What a correct token manager must do
A minimal correct OAuth token manager has four responsibilities:
- Valid check with skew — treat the token as expired N seconds early (typically 30–60s) so callers don't hit the cliff exactly at expiry.
- Single-flight refresh — if the token is expired and a refresh is already in flight, await that one Promise instead of starting a new refresh. This ensures exactly one refresh per expiry event, regardless of how many concurrent callers exist.
- Rotation-merge on persist — if the refresh response omits
refresh_token(Google), keep the previous one. If it returns a new one (Okta/Auth0/Microsoft), save it. Never overwrite withundefined. - Atomic file write — temp file +
rename, mode 0600. Readers never see a partial file; other users can't read the credential.
Complete implementation (one file)
// token-manager.mjs — concurrency-safe OAuth token lifecycle, no dependencies import { writeFile, readFile, rename, mkdir } from "node:fs/promises"; import { dirname } from "node:path"; // ─── Stores ──────────────────────────────────────────────────────────────── export function memoryStore(initial = null) { let creds = initial; return { async get() { return creds; }, async set(c) { creds = c; } }; } export function fileStore(path) { return { async get() { try { return JSON.parse(await readFile(path, "utf8")); } catch { return null; } }, async set(creds) { await mkdir(dirname(path), { recursive: true }).catch(() => {}); const tmp = `${path}.${process.pid}.${Date.now()}.tmp`; await writeFile(tmp, JSON.stringify(creds), { mode: 0o600 }); // owner-only await rename(tmp, path); // atomic swap } }; } // ─── Rotation merge ──────────────────────────────────────────────────────── function mergeRotation(prev, res) { const merged = { ...res }; if (!merged.refresh_token && prev?.refresh_token) { merged.refresh_token = prev.refresh_token; // keep Google's refresh token } if (merged.expires_in && !merged.expires_at) { merged.expires_at = Date.now() + merged.expires_in * 1000; } return merged; } // ─── Token manager ───────────────────────────────────────────────────────── // opts: { store, refresh: async(prev)=>newCreds, skewMs?, onRefresh? } export function createTokenManager(opts) { const store = opts.store || memoryStore(); const skewMs = opts.skewMs ?? 60_000; let cache = null; let loaded = false; let inflight = null; // single shared refresh Promise function isValid(c) { if (!c?.access_token) return false; if (c.expires_at == null) return true; return c.expires_at - skewMs > Date.now(); } async function load() { if (!loaded) { cache = await store.get(); loaded = true; } return cache; } async function doRefresh(prev) { let next = await opts.refresh(prev); next = mergeRotation(prev, next); if (!next?.access_token) throw new Error("refresh returned no access_token"); cache = next; await store.set(next); opts.onRefresh?.(next); return next; } return { async getValidToken() { const c = await load(); if (isValid(c)) return c.access_token; // fast path // SINGLE-FLIGHT: if a refresh is already running, await THAT one if (!inflight) { inflight = doRefresh(cache) .then(next => { cache = next; return next; }) .finally(() => { inflight = null; }); } return (await inflight).access_token; }, async invalidate() { // Force a refresh on the next call (e.g. server returned 401) const c = await load(); if (c) { c.expires_at = 0; cache = c; await store.set(c); } }, async peek() { return await load(); } // read current creds without refreshing }; }
Usage examples
In-memory (lifetime of one process)
const tokens = createTokenManager({
store: memoryStore(storedCreds),
refresh: async (prev) => {
const r = await fetch(TOKEN_URL, { method: "POST", body: form(prev.refresh_token) });
return r.json();
}
});
const access = await tokens.getValidToken(); // use this in all API calls
File store (persists across process restarts)
const tokens = createTokenManager({
store: fileStore("~/.myapp/creds.json"),
refresh: async (prev) => refreshWithProvider(prev),
onRefresh: (next) => console.error("token rotated; expires", new Date(next.expires_at))
});
// Works correctly even if two processes call this concurrently on the same file
// (in-process race → single-flight; multi-process race → add cross-process lock)
const access = await tokens.getValidToken();
Handling 401s from the server
const access = await tokens.getValidToken();
const r = await fetch(API_URL, { headers: { Authorization: `Bearer ${access}` } });
if (r.status === 401) {
// Server rejected our token (clock skew, server-side revocation, etc.)
// Re-read first — a sibling may have just refreshed
const fresh = await tokens.peek();
if (fresh && fresh.access_token !== access) {
// Another caller already rotated → retry with the fresh token
return fetch(API_URL, { headers: { Authorization: `Bearer ${fresh.access_token}` } });
}
await tokens.invalidate(); // force refresh on next call
const access2 = await tokens.getValidToken();
return fetch(API_URL, { headers: { Authorization: `Bearer ${access2}` } });
}
Multi-process: add a cross-process lock
If multiple OS processes share one fileStore, the in-process single-flight is not enough — each process has its own inflight state. Add a lock file around the refresh section using O_CREAT|O_EXCL and re-read after acquiring it. See the cross-process lock guide for the complete code.
Use the library instead
If you'd rather not own this module, refresh-guard ships the same pattern (single-flight + rotation-merge + atomic fileStore) as a zero-dependency MIT package.
npm i refresh-guardimport { createTokenManager, fileStore } from "refresh-guard";
const tokens = createTokenManager({
provider: "google", // optional: loads provider quirks profile
store: fileStore("~/.myapp/creds.json"),
refresh: async (prev) => fetchNewToken(prev.refresh_token)
});
const access = await tokens.getValidToken();
Source on GitHub · npm i refresh-guard.
FAQ
What should a Node.js OAuth token manager do?
Expose a single getValidToken() that returns a valid access token, handling expiry and refresh transparently. Internally: valid check with skew, single-flight refresh, rotation-merge on persist, atomic file write. Callers never need to know whether a refresh happened.
How do I store OAuth tokens securely in Node.js?
Write with mode 0600 (owner-only) via atomic temp-file-rename: write to a temp file on the same filesystem, then rename onto the target. This prevents other local users reading the credential and concurrent readers seeing a partial file.
Memory store vs file store — when to use which?
Memory store: token lives only for the current process lifetime (web server, single CLI run). File store: token persists across restarts (CLI tool, daemon, cron job). If multiple processes share one file store, add a cross-process lock.
How do I handle invalid_grant in a token manager?
Before propagating to the caller, re-read the stored token. A sibling process may have already refreshed and written a fresh token — if so, use it and recover silently. Only trigger re-authentication if the token after re-read is still invalid.