TL;DR
- Rotation-merge: if the refresh response contains
refresh_token, save it. If it omitsrefresh_token, keep the previous value. One rule, all providers. - Atomic write: write to a temp file (
TOKEN_PATH + ".tmp") thenrenameonto the target.renameis 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_tokenwithundefinedon 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;
}
| Strategy | Okta/Auth0/Microsoft | |
|---|---|---|
Naive overwrite: save(res) | Erases refresh_token → forced re-consent | Correct (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 present | Correct | Correct |
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
}
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
| Provider | Rotates refresh_token? | Returns refresh_token on refresh? | Action on persist |
|---|---|---|---|
| No | No (omits it) | Keep old refresh_token (merge) | |
| Okta | Yes | Yes (new RT) | Save new refresh_token from response |
| Auth0 | Yes | Yes (new RT) | Save new refresh_token from response |
| Microsoft Entra | Yes | Yes (new RT) | Save new refresh_token from response |
| Salesforce | Yes | Yes (new RT) | Save new refresh_token from response |
| GitHub OAuth apps | No | Yes (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-guardimport { 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.