In this article
August 31, 2026
August 31, 2026

Refresh token behavior across fourteen providers

Which providers rotate refresh tokens, which return expires_in, which give you a grace period, and which revoke on reuse. One row per provider, verified against provider documentation in August 2026.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

A customer's Slack sync stops at 2am and every request comes back invalid_grant. You have a dozen providers wired into your app, and the reason that token died is different for every one of them. Google's refresh token quietly stops working after six months of disuse. A Box refresh token is good for exactly one use. Atlassian has a reuse window you cannot see from the error payload.

Prose about OAuth refresh does not help at 2am. A table does. What follows is the documented refresh behavior for fourteen providers, in fifteen rows, because GitHub's two app types behave nothing alike. Every claim links to the provider's own documentation, and where a widely believed behavior is not actually documented, the row says so rather than repeating it.

Grid of provider cards holding bars of different lengths, one card highlighted, standing in for per-provider token lifetimes.

The four questions you are actually asking

Every refresh-loop bug reduces to one of these:

  1. How long is the access token good for, and does the provider tell you?
  2. Does the refresh call hand back a new refresh token, invalidating the old one?
  3. If your write-back fails after a successful refresh, is there a grace period?
  4. What kills a refresh token when nobody touched anything?

The columns below map to those questions in that order.

The table

Provider Access token Refresh token Rotates Grace window What kills it
Google expires_in on every refresh response No fixed clock No Not applicable Six months unused; password change when the token carries Gmail scopes; the 100-token-per-account-per-client cap silently invalidating the oldest; a Cloud session policy, which returns invalid_grant with error_subtype such as invalid_rapt on sessions as short as an hour. In Testing status with an external user type, 7 days, unless the only scopes are name, email, and profile
GitHub App
expiring user tokens
8 hours, expires_in always 28800 6 months, refresh_token_expires_in always 15897600 Yes None documented Using the refresh token retires both it and the old access token. Tokens issued while expiry was disabled never expire, even after you re-enable it; clear them with DELETE /applications/CLIENT_ID/token
GitHub OAuth app No fixed clock See note Not in the documented web flow Not applicable User revocation, one year of non-use, or leak detection. GitHub's comparison page says OAuth apps can opt into 8-hour tokens with a refresh token, but links to a section that no longer exists, so treat expiring OAuth app tokens as undocumented
Slack
rotation enabled
12 hours, expires_in always 43200 One use Yes A short grace period after use, duration not published. A burst guard caps you at 2 active tokens within 12 hours and revokes the oldest extra Rotation cannot be switched off once enabled
Salesforce Whatever the connected app's session timeout says. No expires_in in the response No documented clock Only if refresh token rotation is enabled on the app None. Simultaneous identical refreshes fail intermittently and show as “Failed: Token request is already being processed” in Login History Reusing a rotated-out token invalidates the current refresh token and its access tokens, and the client must run a new flow
Atlassian
Jira, Confluence
expires_in in the token response 90 days, reset on every use Yes 10-minute reuse leeway, during which breach detection does not fire. Documented as existing to avoid network concurrency issues 90 days of inactivity, or an account password change. Failure is a 403 invalid_grant with “Unknown or invalid refresh token”
GitLab 7,200 seconds by default, admin-configurable since 19.1 Usable even after the access token expires Yes None documented Every refresh invalidates both the old access token and the old refresh token
Box 60 minutes 60 days, single use Yes None. Exactly one use 60 days without a refresh, after which the user must be re-authorized
Dropbox Short-lived. The exact expiry comes back from the token endpoint; Dropbox deliberately publishes no number No documented expiry No Not applicable Explicit revocation only. The user's approval stands until revoked
Asana 1 hour, expires_in 3600 Long-lived, good for as long as the user's authorization stands Documents no rotation Not applicable Revoking the refresh token also deauthorizes its bearer tokens. Access tokens are rejected by the revoke endpoint
HubSpot 1,800 seconds No documented lifetime Not documented either way. Non-rotation is observed, not published None documented invalid_grant with "status": "BAD_REFRESH_TOKEN"
Notion
public connection
Not documented Not documented Yes Not stated Notion says to store the token pair from each successful authorization response, re-authorizations included. Note refresh_token is typed nullable while marked required
Linear 24 hours, expires_in 86399 Not documented Yes 30 minutes, and the original request is replayable to retrieve the new token All OAuth applications were migrated to a new refresh token system on 1 April 2026. Linear does not document what changed
Intercom No expires_in in the documented response, which carries only token_type, token, and access_token. Intercom publishes no expiry None issued No refresh token exists Not applicable Re-run OAuth. Each region has its own authorization host, and using the wrong one adds a region-picker step, or hard-fails for Google sign-in
Sentry 8 hours. No expires_in; the response carries expiresAt as an ISO 8601 string alongside token and refreshToken New one on every refresh Yes None on the refresh token itself Sentry marks refreshing via refresh token as “not recommended” and recommends a manual refresh instead, because a refresh can commit on their side and be lost in transmission

Rotation is the column that breaks your database

Fifteen provider rows grouped into five buckets by what a crash between refresh and commit costs. "No refresh token, nothing to drop": GitHub OAuth app, Intercom. "No rotation, a lost write costs a token": Google, Dropbox, Asana, HubSpot. "Rotates, with a window, recoverable inside it": Slack, Atlassian, Linear. "Rotates, no window, costs you the connection": GitHub App, GitLab, Box, Notion, Sentry. "Depends on a setting, either of the above": Salesforce.
The bottom two buckets decide your incident. Everything above them costs a request.

Eight of these fifteen rows rotate on every refresh. Four do not, two have no refresh token at all, and Salesforce makes rotation a setting. That one property decides how you write the refresh path.

When the provider does not rotate, as with Google, Dropbox, and Asana, a failed write after a successful refresh costs you an access token. Retry and move on. Google's docs say plainly to keep the stored refresh token in long-term storage and use it as long as it works.

When the provider does rotate, a failed write costs you the connection. Box states it exactly: 60 days, one use, and a new refresh token comes back with every access token. Atlassian says the same and names the failure. If your app is not replacing the previous refresh token with the new one, you get invalid_grant and the user reauthorizes from scratch.

So the token write and the API call cannot share an optimistic block. The ordering that survives a crash is persist, commit, then call:

  
const fresh = await refreshWithProvider(connection);

// Commit the new pair before anything else can consume it.
await db.transaction(async (tx) => {
  await tx.connections.update(connection.id, {
    accessToken: fresh.accessToken,
    refreshToken: fresh.refreshToken ?? connection.refreshToken,
    expiresAt: fresh.expiresAt,
  });
});

return callProviderApi(fresh.accessToken);
  
Two sequences with the crash at the same point in both. The wrong order runs refresh, then call the API, then persist; the process dies before the persist step and the outcome is a lost connection. The order that survives runs refresh, then persist, then call the API; the process dies before the API call and the outcome is a single failed request.
Same crash, same moment. The only difference is what you had already committed.

If the commit throws, the connection is already holding a refresh token the provider has retired, and the only safe move is the provider's grace window or a fresh consent flow. The ?? connection.refreshToken fallback is there for the non-rotating rows, where the response has no refresh_token to store.

The nastiest rows are the configurable ones. Salesforce rotation is a checkbox on the connected app, and Slack rotation is a switch you cannot flip back once it is on. Same provider, same code path, two different failure models depending on a setting made in someone else's admin console.

Grace periods are the only reason your retries work

Rotation tells you what a crash costs. The grace window tells you whether you can get it back, and only three providers publish one.

Linear allows a 30-minute replay: make a refresh request with a valid refresh token, lose the response, and you can replay the original request for up to 30 minutes to retrieve the new refresh token. Atlassian's default reuse interval is 10 minutes, during which breach detection does not fire when the same refresh token is exchanged more than once, and the docs say the interval exists to avoid network concurrency issues. Slack revokes the used refresh token after a short grace period without naming a duration, and caps you at 2 active tokens.

Box is the other corner: one use, no window at all. A crash between Box's response and your commit is a lost connection, full stop.

Salesforce goes further and tells you not to send concurrent refreshes: identical simultaneous requests fail intermittently, and Login History records "Failed: Token request is already being processed". If you run more than one worker per connection, that error is your own concurrency, not a Salesforce outage.

So the pair of columns is what matters. Rotation plus no grace window, which is Box, GitHub App, and GitLab, means a crash between refresh and commit costs you a customer rather than a request.

expires_in is not a promise

Four different shapes show up in that one field. GitHub returns a constant, always 28800 for the access token and always 15897600 for the refresh token. Slack does the same with 43200. Dropbox returns the real expiry each time and deliberately never publishes a number. Sentry skips expires_in entirely and returns an expiresAt ISO 8601 string alongside token and refreshToken, which is also a reminder that its response is camelCase while its request body is snake_case.

Salesforce is the outlier worth a code comment. The documented response parameters for the refresh token flow are access_token, refresh_token when rotation is on, token_type, token_format, instance_url, id, issued_at, signature, and the Experience Cloud site fields. No expires_in. The expiry lives in the connected app's session timeout setting, which your code cannot read. Refresh-if-expired logic keyed on expires_in will treat a Salesforce token as immortal and find out otherwise when a call fails.

The idle killers

Tokens die when nobody is looking, and the reasons are specific enough to earn a runbook line each.

Google offers four separate ways to lose a refresh token without doing anything wrong: six months of disuse, a password change when the token carries Gmail scopes, the 100-token-per-account-per-client cap where a new token silently invalidates the oldest, and Cloud session control policies that produce invalid_grant with an error_subtype such as invalid_rapt on sessions as short as an hour. Atlassian expires a rotating refresh token after 90 days of inactivity, resetting the clock on each use, and lists an account password change as a cause. Box's 60 days is the shortest idle window in the table. GitHub's OAuth app tokens are revoked after a year of non-use.

All of these arrive as the same invalid_grant that woke you up. Terminal and transient look identical on the wire, which is why the runbook has to be per-provider.

What this costs when you get it wrong

Your customer finds out before your monitoring does. Every dropped rotation ends in a reauthorization prompt, and then a ticket that opens with "your integration keeps disconnecting."

The silent variants are worse. GitHub tokens issued while expiry was disabled never expire, even after you re-enable it, so a migration can leave you holding long-lived credentials you believe are short-lived. GitHub documents the cleanup for that case, DELETE /applications/CLIENT_ID/token, and it is worth running rather than assuming.

The fair objection to a table like this is that it is a snapshot, and snapshots rot. Linear migrated every OAuth application to a new refresh token system on 1 April 2026. HubSpot is retiring four v1 OAuth endpoints on 16 February 2027, specifically POST /v1/token, GET /v1/access-tokens/{token}, GET /v1/refresh-tokens/{token}, and DELETE /v1/refresh-tokens/{token}, because they carry secrets in paths and query strings; the replacements are the date-versioned /oauth/2026-03/token family. Everything above was verified against provider documentation in August 2026, and every row links to its source so you can check a claim rather than trust it.

Where Pipes fits

This matrix is what WorkOS Pipes exists to absorb. Pipes launched with GitHub, Google, Slack, and Salesforce and now lists more than 300 providers, including every one in the table above. The refresh token stays in Pipes rather than in your database, held in Vault and encrypted at rest with AES-256, and your code makes one call:

  
const { accessToken, error } = await workos.pipes.getAccessToken({
  provider: 'github',
  userId,
  organizationId,
});

// error is 'needs_reauthorization' or 'not_installed'
if (!accessToken) return promptReconnect(error);

const token = accessToken.accessToken;
  

One call returns a valid token, and Pipes handles expiry, refresh, and concurrency behind it. The shape is the same for every provider above.

The trade is real and worth naming: you no longer hold the refresh token, and a token fetch becomes a call to us instead of a lookup in your own database. If you are single-provider and your app needs the raw credential, keep your own loop and treat the table above as your runbook. Past two or three providers, the rotation bugs compound faster than you can special-case them.

Either way, take one thing from the table: find your providers in the rotation column, then check whether they publish a grace window. That pair decides whether a crash between refresh and commit costs you a request or a customer. The rest of this is runbook detail. That one is a data-loss bug.