AllAboutAPIs

Token Refresh Handling in Long-Running Integrations

State-management failures, not OAuth bugs, cause token refresh disasters in production systems.

Staff Writer · · 10 min read
Cover illustration for “Token Refresh Handling in Long-Running Integrations”
Auth & Security · August 4, 2026 · 10 min read · 2,351 words

Most OAuth token refresh bugs in production are not OAuth bugs. They are state-management bugs. Once you internalize that distinction, every weird failure starts making sense, and you can actually fix things instead of just restarting services and hoping.

The access token is supposed to be short-lived. One to two hours is typical. Sensitive APIs push that down to five to fifteen minutes, per RFC 9700 guidance from January 2025. The refresh token is what keeps the integration alive across those expirations. If your refresh handling is broken, your integration is broken, and it will not look broken right away.

Prototype environments hide this completely. One process, one request path, no queue pressure. Everything works great. Then you ship to production, where you have background jobs, webhook handlers, retry queues, multiple app instances, and multi-tenant traffic all running at the same time. The failures don't crash your app. They make things quietly stop working. Bug triage pauses. Ticket creation stops. Scheduled syncs skip. Nothing in the logs necessarily points at token expiry.

Here's what actually helps.

What RFC 9700 Requires from Refresh Handling

RFC 9700 dropped in January 2025 from the IETF. It's the current definitive document on OAuth 2.0 security and supersedes the threat models in RFCs 6749, 6750, and 6819. If your team is working from older references, you're working from outdated guidance.

The parts that matter directly for refresh handling:

  • Rotation on use is mandatory. Refresh tokens must rotate every time they're used. If a previously used token gets submitted again, the provider should detect the reuse and trigger revocation of the entire token family.
  • Public clients have no exception. Refresh tokens for public clients must be either sender-constrained or rotated.
  • Sender-constraining is now baseline, not advanced. The spec recommends mutual TLS (RFC 8705) or DPoP (RFC 9449) for both access tokens and refresh tokens. If your system was designed before RFC 9700, there's a real chance your public-client flows are out of spec.
  • Scope minimization is a refresh concern too. A refreshed token inherits whatever scope was originally granted. Overly broad scopes don't go away on refresh. They persist.

RFC 9700 deliberately does not resolve token lifetimes and rotation specifics. Those are left to each provider. That decision is technically correct and practically painful, because it is the exact reason provider divergence becomes your problem later.

Proactive Refresh vs. Reactive Retry Alone

Two strategies exist. You need both.

Reactive refresh means you catch a 401, attempt a token refresh, and retry the original request. This is the minimum viable approach and is not enough on its own.

Proactive refresh means you renew the token before it expires, on a background schedule. This prevents the failed request from happening in the first place.

The recommended threshold: refresh when roughly 75% of the token's lifetime has elapsed, with a background check running every 60 seconds. That heuristic comes from production experience, not theory.

Why proactive refresh matters specifically for long-running integrations:

  • A scheduled job running at 3am doesn't get a graceful retry window if the token expired two minutes before the job ran.
  • Long-polling connections, streaming responses, and multi-step workflows can't absorb a mid-flight expiry without breaking.

When proactive refresh itself fails, you need exponential backoff with jitter. Without jitter, a fleet of workers that all failed at the same moment will re-synchronize their retry attempts and hit the provider in another coordinated wave.

One calibration note: the 75% threshold is a starting point. A 5-minute access token needs a very different trigger point than a 60-minute one. Adjust based on the provider's actual lifetime, not the textbook default.

Race Conditions Across Multiple Workers Refreshing Simultaneously

Here's the concrete failure sequence:

  1. Worker A sees the access token is expired. Fetches the stored refresh token. Initiates a refresh request.
  2. Worker B sees the same expired access token a moment later. Fetches the same refresh token. Initiates its own refresh request.
  3. Worker A succeeds first. Receives a new token pair. Stores the new refresh token. The old one is now invalid.
  4. Worker B submits the now-invalid refresh token. Gets an error. If rotation is enabled, that error extends to invalidating the entire token family.

That's a two-worker example. Scale it up. Fifty services sharing a machine-to-machine JWT all see it expire at the same second and simultaneously hit the identity provider. The refresh operation itself takes anywhere from 100 milliseconds to several seconds, and that window is exactly when a second worker acts on stale state.

Single-instance solution: an in-memory lock. One goroutine or thread performs the refresh. Others wait on the result. Simple and effective at one instance.

Distributed solution: a single-flight lock in Redis using SET lock_key token NX PX ttl.

  • The lock's own TTL prevents a crashed process from holding the key indefinitely.
  • Waiting workers should poll for the result of the refresh rather than each attempting their own.

One caveat worth knowing: Redis does not use a monotonic clock for TTL expiration. A wall-clock shift can result in two processes acquiring the same lock simultaneously. This is documented by Redis itself and is a known edge case, not a theoretical one.

Complementary tool: stagger your background refresh schedules using TTL = base + rand(0, N). This prevents a fleet of workers from synchronizing their refresh cycles in the first place.

What Rotation Guarantees and What It Doesn't

Rotation's logic: each refresh token is single-use. If a previously used token gets submitted again, the provider detects reuse and invalidates the most recently issued tokens and all access tokens issued since authentication. That's documented behavior from providers like Okta.

This is exactly why the race condition above is so damaging under rotation. The losing worker doesn't just get a 401; it triggers a cascade revocation.

A less-obvious property of rotation: when a token rotates, the new refresh token inherits the original expiry date. The lifetime does not reset; it carries forward from the token minted at first authentication. A refresh token that has been in active use for 85 of its 90-day lifetime has five days left, regardless of how many times it has rotated.

The critical storage requirement under rotation: the new refresh token must be written atomically before any other worker can act on the old one. A non-atomic write is a race condition waiting for the right moment.

What rotation does not protect against: an attacker who steals and uses the refresh token first will successfully rotate it. The legitimate application then presents the now-invalid old token and gets revoked. The attacker retains access and the legitimate integration is locked out. Rotation detects reuse but does not prevent the initial theft.

For SaaS-to-SaaS integrations specifically, refresh tokens stored in third-party systems are a lateral movement vector. They bypass SSO and MFA entirely. Rotation helps you detect the attack after the fact; it does not stop the theft.

Where to Store Refresh Tokens Safely

The stakes are asymmetric. A compromised access token is bad for a few minutes to an hour; a compromised refresh token can sustain unauthorized access for days to months, until it expires or gets revoked.

Where they should not be stored:

  • Browser storage. localStorage and sessionStorage expose tokens to any JavaScript running on the same origin. An XSS vulnerability becomes a credential theft event.
  • Resource servers. API handlers, logs, and tracing paths should never see a refresh token. Keeping them out of these paths limits the attack surface.
  • For SPAs: use memory-only storage. Better yet, use the Backend for Frontend (BFF) pattern to keep refresh tokens off the client entirely.

Where they should be stored, for server-side multi-tenant SaaS:

  • Encrypted database column. Practical starting point. Keeps token metadata alongside application state. Manageable at moderate scale.
  • Secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.). Better at scale. Reduces casual credential exposure across teams and services. Provides audit trails for access.

The tradeoff is operational overhead versus blast radius if the credential store is compromised. Both options are better than plaintext database columns, which still show up more often than anyone would like to admit.

One connection back to rotation: atomicity of writes is not just a concurrency concern. It's a security concern, because a partial write that leaves the old token readable creates a brief window of credential duplication.

DPoP as a Hardening Layer Against Exfiltration

The threat DPoP addresses: a refresh token stored anywhere can be stolen. DPoP makes that stolen token useless without the private key that was bound to it.

The mechanism: when requesting or refreshing tokens, the client presents a signed JWT that proves possession of a private key. The token endpoint won't issue or accept tokens without that proof. A DPoP-bound refresh token stolen from a database cannot be replayed by an attacker who doesn't also have the private key.

DPoP and PKCE are complementary, not redundant. They address different phases:

  • PKCE protects the authorization code during the initial redirect flow.
  • DPoP sender-constrains access tokens and optionally refresh tokens against replay after issuance, by binding them to a client's private key.

RFC 9700 codifies DPoP (RFC 9449) and mutual TLS (RFC 8705) as the recommended sender-constraining mechanisms. This is normative guidance now, not an experimental pattern.

DPoP support varies meaningfully by provider. It's an opt-in hardening layer for teams where token exfiltration is a credible threat model, not a baseline requirement for every integration today.

Handling Errors When Refresh Fails

Three error codes show up most often, and they mean different things:

  • invalid_grant: The token is expired, revoked, malformed, already consumed by rotation, or out of sync with stored state. If you see this intermittently, suspect a concurrency problem before you suspect the provider.
  • invalid_client: Client authentication is wrong. Check client ID, client secret, token endpoint auth method, and whether your staging and production environments match.
  • unauthorized_client: The client isn't permitted to use the refresh grant type, or provider configuration doesn't allow refresh for this app. This is a configuration problem, not a token problem.

Some failures are not retryable at all. Microsoft Entra and Azure AD have documented force-revocation events that no amount of refresh logic will overcome:

  • Account disabling or deletion invalidates refresh tokens immediately.
  • Admin revocation of app consent instantly invalidates every refresh token tied to those apps.
  • User-initiated consent withdrawal takes effect immediately.
  • Password resets or MFA settings changes invalidate all current refresh tokens.
  • Enterprise governance jobs can force-revoke app consents at scale on automated schedules.

These are not bugs. They are intentional, and your integration needs to detect them and respond gracefully rather than retry indefinitely.

Graceful degradation pattern:

  1. Distinguish recoverable failures (transient network errors, rate limits) from terminal failures (revoked consent, deleted accounts).
  2. On terminal failure, surface a clear re-authentication prompt to the user or operator rather than silently queueing failed operations.
  3. Log the specific error code and the tenant and user context. Vague "integration error" messages make these nearly impossible to diagnose at scale.

Provider-Specific Behaviors That Break Spec Assumptions

OAuth 2.0 is a framework, not a complete protocol. Refresh behavior is deliberately left to implementers, which means every provider is technically compliant while behaving differently.

Concrete examples:

  • Microsoft Entra ID: Refresh token max inactive time is 90 days. Refresh and session token lifetimes are no longer configurable through token lifetime policies. The system uses only defaults, which limits what integrations can control.
  • Azure AD B2C: Default refresh token lifetime is 90 days for standard flows. SPA and email OTP flows have different defaults. Same provider, different behavior depending on which flow type you're using.
  • Rotation policies: Some providers mandate rotation. Others leave it optional. A refresh flow written for a non-rotating provider will mishandle the new token in the response from a rotating one.

The operational consequence is that you end up maintaining a slightly different refresh flow per provider, with different lifetime assumptions, rotation behavior, error responses, and revocation triggers.

This is not solvable at the OAuth layer. It requires a provider-aware abstraction layer that encapsulates each provider's quirks rather than a single generic handler. Teams that try to write one universal refresh handler end up with a handler that's subtly wrong for every provider.

The per-provider maintenance burden also compounds over time. Microsoft's removal of configurable token lifetimes is a recent example of a policy change that broke assumptions baked into existing integrations. Providers update their behavior, and your abstraction layer needs to absorb those updates without rewriting core logic.

A Refresh Architecture That Holds Under Load

Here's what the full picture looks like when you put it together:

Layer 1: Proactive background refresh. Check token expiry on a loop. Refresh at 75% of lifetime elapsed. Apply jitter to prevent synchronized refresh cycles across a fleet.

Layer 2: Distributed locking. Use Redis or equivalent to ensure only one worker performs a refresh at a time. Set a TTL on the lock. Have waiting workers poll for the result rather than racing.

Layer 3: Atomic storage writes. Write new tokens atomically. Use an encrypted database column or a secrets manager. Never leave a window where the old token is readable alongside the new one.

Layer 4: Provider-aware abstraction. Encapsulate each provider's lifetime assumptions, rotation behavior, and error responses behind a clean interface. Don't let provider quirks bleed into shared refresh logic.

Layer 5: Error classification. Distinguish recoverable from terminal failures at the point of error. Surface re-authentication prompts for terminal failures. Log specific error codes with context.

Layer 6: DPoP where the threat model warrants it. Bind tokens to a private key if token exfiltration is a credible risk in your environment. Not every provider supports it yet.

Layer 7: Reactive retry as a safety net. Catch 401s, attempt refresh, retry. This should be the exception, not the primary path. If it's firing regularly, something upstream in layers one through three is broken.

None of these layers are complicated individually. The complexity comes from their interaction. A proactive refresh without locking creates race conditions. Locking without atomic writes creates a subtler race condition. Atomic writes without error classification leave you retrying unrecoverable failures indefinitely. The integration that survives production load is the one that handles all of these layers together.

Sources

  1. nango.dev
  2. obsidiansecurity.com
  3. useparagon.com
  4. frontegg.com
  5. mallary.ai
  6. redis.io
Filed underAuth & Security

More in Auth & Security