OAuth2 engineering › Token lifecycle › Node.js token manager pattern

Node.js OAuth token manager — the complete pattern

getValidToken() with single-flight coalescing, rotation-merge, and an atomic file store. One module, no dependencies, handles any provider.

Node.js ≥18 · copy-paste · no trackers, no cookies. Last updated 2026-05-31.

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:

  1. 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.
  2. 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.
  3. 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 with undefined.
  4. 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-guard
import { 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.