OAuth2 engineering › Token lifecycle › Expiry, clock skew & proactive refresh

OAuth token expiry and clock skew — proactive refresh and picking the right margin

Clock differences between your machine and the provider cause tokens to be rejected as expired while your local check still thinks they are valid. Refresh slightly early — but still need single-flight.

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

TL;DR

  • Clock skew — your clock and the provider's clock differ; tokens may be rejected by API servers before your local check says they expired. A 30–60s skew margin covers this.
  • Proactive refresh — treat the token as expired SKEW_MS before its real expiry. Reduces the cliff where many callers all discover expiry at once.
  • Still need single-flight — proactive refresh opens a window, not a point. Multiple callers can all enter that window at the same tick. Single-flight ensures one refresh happens.
  • Convert expires_inexpires_at immediately — store the absolute timestamp, not the relative offset.

The clock-skew problem

An OAuth access token's lifetime is encoded as expires_in (seconds from issuance) in the token response. The provider stamps expiry relative to its clock. Your code computes expiry relative to your clock. If the two clocks differ:

  • Your clock is fast (ahead of the provider): your code thinks the token expired before the provider does. You unnecessarily refresh early — harmless, just slightly wasteful.
  • Your clock is slow (behind the provider): you send a token to an API server that treats it as already expired, even though your local check says it's valid. You get a 401 without having gone through the refresh path. This is the dangerous direction.

A proactive skew margin addresses both: refresh early enough that even if your clock is slow relative to the API server's, the token you use is still valid by the server's clock.

The right skew margin: 30–60 seconds

For access tokens with a 1-hour lifetime (the common Google/Okta/Auth0 default), a 60-second margin is conventional:

  • Clock drift between NTP-synced machines is typically under 100ms. Even with degraded NTP, drift rarely exceeds a few seconds. 60s is a 600x safety factor over this.
  • Network round trip for the refresh itself is under 2 seconds on most connections. You refresh early enough that the new token is ready before the old one is rejected.
  • Tokens with shorter lifetimes (5-minute access tokens) may use a smaller margin (30s), but the same principle applies.
const SKEW_MS = 60_000;  // 60 seconds in milliseconds; adjust if tokens are shorter-lived

function isValid(creds) {
  if (!creds?.access_token) return false;
  if (creds.expires_at == null) return true;        // no expiry info → assume valid
  return creds.expires_at - SKEW_MS > Date.now();  // true if still valid with skew margin
}

Convert expires_in to expires_at immediately

expires_in is relative to the moment the token was issued. If you store the raw expires_in, every validity check needs to also know the issuance time, which is fragile. Convert to an absolute millisecond timestamp as soon as you receive the response — before any await that might introduce delay:

async function handleRefreshResponse(prev, body) {
  const merged = mergeRotation(prev, body);

  // Convert expires_in → expires_at IMMEDIATELY (do not defer past any await)
  if (merged.expires_in && !merged.expires_at) {
    merged.expires_at = Date.now() + merged.expires_in * 1000;
  }

  return merged;
}
Don't defer the timestamp conversion. If you convert after an await (a DB write, a network call), the elapsed time is silently subtracted from the token lifetime. For a 1-hour token, 5 seconds of deferral is negligible; for a 5-minute token, 30 seconds of processing time before conversion produces a materially shorter window.

Why proactive refresh still needs single-flight

Proactive refresh changes a sharp expiry cliff into a window (the skew window). All callers that check isValid() within that window at the same event-loop tick still see the token as expired and each wants to start a refresh. Without single-flight, you still get N concurrent refresh requests — just 60 seconds earlier. Single-flight is still required to ensure exactly one refresh happens per expiry event.

Think of it this way: proactive refresh reduces the number of callers that discover expiry at exactly the same instant (because it opens the refresh window early, before real expiry, when traffic may be lower). Single-flight handles the concurrent callers that hit the window at the same time, regardless of when the window opens.

Handling tokens with no expiry information

Some providers and some flows return tokens with no expires_in. Options in priority order:

  1. Treat as indefinitely valid; handle 401 from the API. On 401, call invalidate() (sets expires_at = 0), then call getValidToken() again to trigger a refresh. This is always correct — it never sends an actually-expired token, it just relies on the server to tell you when one is.
  2. Assume the provider's documented default. If the provider docs say "access tokens are valid for 3600 seconds," use 3600 * 1000 as a synthetic expires_in on tokens that omit it.
  3. Store the issuance time and use a conservative lifetime. If you know tokens are issued at now and you use 50 minutes as a conservative ceiling, you'll refresh slightly more often than necessary but never send an expired token.

The complete isValid check

const SKEW_MS = 60_000;

function isValid(creds, now = Date.now()) {
  if (!creds || typeof creds !== "object") return false;
  if (!creds.access_token) return false;
  if (creds.expires_at == null) return true;         // no info → assume valid, rely on 401
  return creds.expires_at - SKEW_MS > now;
}

Passing now as a parameter makes the function testable: inject a fixed timestamp in tests to assert behavior at boundary conditions without manipulating the system clock.

Library

refresh-guard uses a 60-second default skew and accepts a custom skewMs option. The now function is injectable for testing.

npm i refresh-guard
const tokens = createTokenManager({
  store: fileStore("~/.app/creds.json"),
  skewMs: 90_000,      // use 90s for tokens served from multiple geo-distributed servers
  now: () => Date.now(), // injectable for tests
  refresh: async (prev) => fetchNewToken(prev.refresh_token)
});

Source on GitHub · npm i refresh-guard.

FAQ

What is clock skew in OAuth token validation?

The difference between your machine's clock and the provider's token server clock. Tokens are stamped using the provider's clock; your code validates using your clock. If your clock is slow, API servers consider your token expired before your local check does, producing unexpected 401s. A proactive skew margin (30–60s) covers this.

How large should the refresh skew margin be?

30–60 seconds for access tokens with 1-hour lifetimes. This absorbs typical clock drift (under 1s on NTP-synced machines), network round-trip for the refresh call (under 2s), and gives a comfortable safety margin. For shorter-lived tokens (5 minutes), use a proportionally smaller margin.

Does proactive refresh eliminate the need for single-flight?

No. Proactive refresh opens a refresh window (e.g. 60s before real expiry) instead of a cliff. Multiple callers can all enter that window at the same tick and each want to start a refresh. Single-flight still ensures exactly one refresh happens. Both are needed.

How do I convert expires_in to expires_at?

expires_at = Date.now() + (expires_in * 1000). Do this immediately when you receive the response, before any async step. Store the absolute timestamp; subsequent validity checks compare against Date.now() without needing the issuance time.

What if the provider returns no expiry information?

Treat the token as indefinitely valid and handle 401 responses from the API by calling invalidate() then getValidToken(). This is always correct — you rely on the server to signal real expiry rather than guessing client-side.