OAuth2 engineering › Token lifecycle › Rotation-merge & atomic persistence

Refresh-token rotation and atomic write

Google omits refresh_token in refresh responses. Okta/Auth0 rotate it. Naive overwrite breaks both. Here's the one merge rule that handles all providers, and how to write the token file atomically.

Vendor-neutral · copy-paste code · no trackers, no cookies. Last updated 2026-05-31.

TL;DR

  • Rotation-merge: if the refresh response contains refresh_token, save it. If it omits refresh_token, keep the previous value. One rule, all providers.
  • Atomic write: write to a temp file (TOKEN_PATH + ".tmp") then rename onto the target. rename is atomic on POSIX; readers never see a partial write.
  • Mode 0600: owner-only read/write. Prevents other users reading your credential.
  • Why naive overwrite fails: overwrites refresh_token with undefined on Google; lets concurrent readers see truncated JSON on any provider.

What refresh-token rotation means

Refresh-token rotation means the provider issues a new refresh token on every successful refresh and immediately revokes the one that was just consumed. This is the default at Okta, Auth0, Salesforce, Microsoft Entra, and recommended by the OAuth 2.0 Security BCP. The sequence:

POST /token { grant_type: "refresh_token", refresh_token: R0 }
→ { access_token: A1, refresh_token: R1, expires_in: 3600 }
  (R0 is now REVOKED; if you send R0 again → 400 invalid_grant)

You must save R1 before anything else reads the credential, or the next refresh sends the revoked R0.

The Google special case

Google's OAuth 2.0 server returns a refresh_token only on the first authorization (the initial consent screen). Every subsequent refresh response looks like this:

{ "access_token": "ya29.xxx", "expires_in": 3599, "token_type": "Bearer" }
// ↑ no refresh_token field

If your persistence code does save(response) — overwriting the stored credentials — you replace the stored refresh_token with undefined (or simply omit it, depending on your JSON serialization). The next time a refresh is needed, there is no refresh_token to send and the user must re-consent from scratch. This is the source of the "Google keeps logging me out" bug.

The rotation-merge rule (handles both)

One merge rule handles Google and rotating providers with the same code:

function mergeRotation(prev, res) {
  const merged = { ...res };

  // If the response omits refresh_token (Google), keep the old one.
  // If it returns a new one (Okta/Auth0/Microsoft), the spread already picked it up.
  if (!merged.refresh_token && prev?.refresh_token) {
    merged.refresh_token = prev.refresh_token;
  }

  // Normalize: convert expires_in (seconds) to expires_at (ms epoch timestamp)
  if (merged.expires_in && !merged.expires_at) {
    merged.expires_at = Date.now() + merged.expires_in * 1000;
  }

  return merged;
}
StrategyGoogleOkta/Auth0/Microsoft
Naive overwrite: save(res)Erases refresh_token → forced re-consentCorrect (new RT is present in response)
Always keep old: save({...res, refresh_token: prev.rt})Correct (old RT still valid)Wrong: ignores the newly rotated RT; next refresh uses revoked token
Rotation-merge: keep old if absent, use new if presentCorrectCorrect

Atomic token persistence (temp file + rename)

A non-atomic write — opening the token file and writing in place — has a window where the file is partially written. A concurrent reader that opens the file during this window sees truncated JSON and fails. On rotating providers, the partial write may have already overwritten the old refresh_token but not yet written the new one, producing a credential with no usable refresh token.

The fix: write to a temporary file on the same filesystem, then rename it onto the target. rename(2) is atomic on POSIX — readers see either the old file or the new file, never a partial state.

import { writeFile, rename, mkdir } from "node:fs/promises";
import { dirname } from "node:path";

async function writeTokenAtomic(path, 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 — readers never see a partial file
}
Note: rename is only atomic when both paths are on the same filesystem (which is always the case for a temp file next to the target). If you write to /tmp/ and rename to a path on a different mount, it may fall back to a non-atomic copy-then-delete. Keep the temp file on the same volume as the target.

Provider reference

ProviderRotates refresh_token?Returns refresh_token on refresh?Action on persist
GoogleNoNo (omits it)Keep old refresh_token (merge)
OktaYesYes (new RT)Save new refresh_token from response
Auth0YesYes (new RT)Save new refresh_token from response
Microsoft EntraYesYes (new RT)Save new refresh_token from response
SalesforceYesYes (new RT)Save new refresh_token from response
GitHub OAuth appsNoYes (same RT)Either save or keep — same token

Verify against each provider's current documentation; rotation behavior can be toggled per-tenant.

One library

refresh-guard ships rotation-merge and atomic file persistence as built-in defaults, so you don't implement them separately.

npm i refresh-guard
import { createTokenManager, fileStore } from "refresh-guard";
const tokens = createTokenManager({
  provider: "google",   // or "okta", "auth0", "microsoft", "salesforce"
  store: fileStore("~/.myapp/creds.json"),   // atomic temp+rename built in
  refresh: async (prev) => fetchNewToken(prev.refresh_token)
});
const access = await tokens.getValidToken();

refresh-guard is by the same team as this guide. Source on GitHub · npm i refresh-guard.

FAQ

What is refresh-token rotation?

Each successful refresh returns a new refresh token and revokes the one just used. Enabled by default at Okta, Auth0, Microsoft Entra, Salesforce. You must save the new token atomically — if another caller reads the credential before you finish writing, it gets a stale or partial token that immediately fails.

Why does Google drop my refresh_token after a refresh?

Google issues refresh_token only on the initial consent. Subsequent refresh responses omit it. If your code does save(response), you overwrite the stored refresh_token with undefined and lose the ability to refresh without a new consent. Fix: rotation-merge — keep the old value when the response omits it.

What is the rotation-merge rule?

If the refresh response contains refresh_token, save it. If it omits refresh_token, keep the previous value. One rule handles Google (always omits) and Okta/Auth0/Microsoft (always rotates) without special-casing.

Why write to a temp file and rename, not write in place?

An in-place write produces a window where the file is partially written — a concurrent reader sees truncated JSON. rename(2) is an atomic syscall on POSIX: readers see either the old file or the new file, never a partial state. Keep the temp file on the same filesystem as the target so the rename doesn't fall back to a copy-then-delete.

What file permissions should a stored OAuth token have?

Mode 0600 — owner read/write only. Pass { mode: 0o600 } to Node's writeFile. This prevents other local users from reading credentials that grant API access.