Skip to content

fix: deduplicate concurrent server-side token refresh calls#33

Merged
nicknisi merged 7 commits into
mainfrom
fix/deduplicate-concurrent-refresh-tokens
May 14, 2026
Merged

fix: deduplicate concurrent server-side token refresh calls#33
nicknisi merged 7 commits into
mainfrom
fix/deduplicate-concurrent-refresh-tokens

Conversation

@nicknisi
Copy link
Copy Markdown
Member

@nicknisi nicknisi commented May 13, 2026

Summary

  • Adds an in-flight Map to AuthKitCore.refreshTokens() so concurrent calls with the same refresh token share a single WorkOS API call instead of racing
  • Keyed by ${refreshToken}:${organizationId ?? ''} — different orgs get separate API calls
  • Map entry cleared in finally on both success and failure

Problem

When a TanStack Start user returns after an idle period, a single page navigation fires multiple concurrent server-function loaders (e.g., 3 for /account). Each runs auth middleware independently, and each calls refreshTokens() with the same expired refresh token. Since refresh tokens are single-use, only the first call succeeds — the rest hit RateLimitExceededException or TokenRefreshError.

Customer-reported: errors from the same IP had different trace IDs, confirming separate concurrent server-fn requests (not multi-tab).

The client-side tokenStore already has refreshPromise dedup per browser tab, but there was zero server-side coordination.

Approach

Mirrors the client-side refreshPromise pattern:

  1. Check inflightRefreshes map for an existing promise with the same key
  2. If found, return it (concurrent callers share the in-flight promise)
  3. If not, create the promise, store it, execute, and clear in finally

AuthKitCore is instantiated once per process (via once() in factory.ts), so the map is effectively process-wide.

Test coverage

4 new tests added to AuthKitCore.spec.ts:

  • Concurrent dedup: 3 simultaneous validateAndRefresh calls → 1 API call, all resolve with same tokens
  • Error propagation: API rejects → all concurrent waiters get TokenRefreshError
  • Retry after failure: failed batch clears map entry, subsequent call retries fresh
  • Org-scoping: same refresh token + different organizationId = 2 separate API calls

Trade-offs

  • First caller's context (userId/sessionId) is used for all waiters' error messages — acceptable since context is telemetry-only
  • Per-process dedup only — does not coordinate across cluster workers (future consideration)

Test plan

  • Reproduction test fails on main (3 API calls observed)
  • Reproduction test passes with fix (1 API call)
  • All 243 existing tests still pass
  • pnpm build passes (TypeScript compilation)
  • authkit-tanstack-start builds cleanly against this change

Open in Devin Review

When multiple server-function requests arrive concurrently with the same
expired access token (e.g., TanStack Start firing 3 parallel loaders),
each independently calls refreshTokens() with the same single-use
refresh token. Only the first succeeds; the rest consume rate-limit
quota or fail with TokenRefreshError.

Add an in-flight Map to AuthKitCore.refreshTokens() keyed by
refreshToken:organizationId. Concurrent callers share one promise;
the map entry is cleared in finally on both success and failure.

This mirrors the client-side refreshPromise dedup in tokenStore but
operates per-process on the server.
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 13, 2026

Greptile Summary

This PR fixes a race condition where multiple concurrent server-side requests each calling refreshTokens() with the same expired refresh token would all hit the WorkOS API independently, causing all but the first to fail since refresh tokens are single-use.

  • Adds a process-wide inflightRefreshes: Map<string, Promise<RefreshResult>> to AuthKitCore; concurrent callers with the same ${refreshToken}\\0${organizationId} key share a single in-flight promise instead of racing.
  • Cleanup (map.delete) is wired via promise.then(cleanup, cleanup) so the entry is cleared on both success and failure, allowing retries after a failed batch.
  • Four new tests cover deduplication, error propagation to all waiters, retry-after-failure, and per-organizationId scoping — all exercised with fake timers against a counting mock client.

Confidence Score: 5/5

Safe to merge — the dedup logic is correct, the null-byte key separator is collision-free for well-formed WorkOS tokens, and cleanup is wired symmetrically on both success and failure paths.

The change is a focused, well-tested addition to a single method. The JavaScript single-threaded execution model guarantees the map entry is set before any concurrent caller can miss it, and the cleanup fires before any awaiting caller's continuation resumes. The four new tests directly verify the core invariants (one API call, error fan-out, retry after failure, and per-org isolation).

No files require special attention.

Important Files Changed

Filename Overview
src/core/AuthKitCore.ts Adds inflightRefreshes Map to deduplicate concurrent refreshTokens calls; uses null-byte separator in the key to safely combine refresh token and org ID; cleanup correctly registered via promise.then before return.
src/core/AuthKitCore.spec.ts Adds 4 new tests covering dedup, error propagation to all waiters, retry-after-failure (map entry cleared), and per-org-ID scoping; uses fake timers correctly to control the async delay inside the counting client.
.github/ISSUE_TEMPLATE/bug_report.md Minor wording update to bug report template to reflect multi-framework support (TanStack Start alongside Next.js).

Reviews (5): Last reviewed commit: "fix: address review feedback from Garen" | Re-trigger Greptile

Comment thread src/core/AuthKitCore.ts Outdated
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 3 additional findings in Devin Review.

Open in Devin Review

Comment thread src/core/AuthKitCore.ts
Comment on lines +233 to +256
const promise = (async () => {
try {
const result =
await this.client.userManagement.authenticateWithRefreshToken({
refreshToken,
clientId: this.clientId,
organizationId,
});

return {
accessToken: result.accessToken,
refreshToken: result.refreshToken,
user: result.user,
impersonator: result.impersonator,
};
} catch (error) {
throw new TokenRefreshError('Failed to refresh tokens', error, context);
} finally {
this.inflightRefreshes.delete(key);
}
})();

this.inflightRefreshes.set(key, promise);
return promise;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Stale rejected promise permanently cached in inflightRefreshes when authenticateWithRefreshToken throws synchronously

The IIFE at src/core/AuthKitCore.ts:233 starts executing synchronously before this.inflightRefreshes.set(key, promise) on line 255. If authenticateWithRefreshToken throws synchronously (i.e., is not an async function), the try/catch/finally inside the IIFE all execute synchronously — meaning this.inflightRefreshes.delete(key) on line 251 runs as a no-op (key not yet in the map), then set(key, promise) stores the already-rejected promise that will never be cleaned up. All subsequent calls with the same refreshToken:organizationId key will return the cached rejected promise, permanently preventing token refresh for that user until the process restarts.

Node.js verification of the execution order

When the awaited expression throws synchronously, finally runs before set:

Order: [ 'delete', 'set' ]
Map has stale entry: true
Map size: 1

When the awaited expression is async (even if it rejects), set correctly runs before finally:

async case: [ 'after-iife', 'finally' ]
Suggested change
const promise = (async () => {
try {
const result =
await this.client.userManagement.authenticateWithRefreshToken({
refreshToken,
clientId: this.clientId,
organizationId,
});
return {
accessToken: result.accessToken,
refreshToken: result.refreshToken,
user: result.user,
impersonator: result.impersonator,
};
} catch (error) {
throw new TokenRefreshError('Failed to refresh tokens', error, context);
} finally {
this.inflightRefreshes.delete(key);
}
})();
this.inflightRefreshes.set(key, promise);
return promise;
const promise = (async () => {
try {
const result =
await this.client.userManagement.authenticateWithRefreshToken({
refreshToken,
clientId: this.clientId,
organizationId,
});
return {
accessToken: result.accessToken,
refreshToken: result.refreshToken,
user: result.user,
impersonator: result.impersonator,
};
} catch (error) {
throw new TokenRefreshError('Failed to refresh tokens', error, context);
}
})();
this.inflightRefreshes.set(key, promise);
promise.finally(() => this.inflightRefreshes.delete(key));
return promise;
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

nicknisi added 2 commits May 13, 2026 17:04
- Extract RefreshResult type alias to deduplicate the Map field and
  method return type (prevents drift)
- Hoist newJwt constant and makeExpiredSession() to file scope
- Add makeCountingClient() helper to eliminate 4 near-identical mock
  client declarations in dedup tests
- Remove shadowing newJwt redeclaration in validateAndRefresh block
- Use null byte delimiter in cache key to prevent theoretical
  collision if a refresh token contains ':'
- Move map cleanup from IIFE finally to external .then(cleanup, cleanup)
  to guarantee delete runs after set, even if the SDK method throws
  synchronously
@nicknisi nicknisi requested a review from gjtorikian May 13, 2026 22:07
Comment thread .github/ISSUE_TEMPLATE/bug_report.md Outdated
Comment thread src/core/AuthKitCore.spec.ts Outdated
getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id',
authenticateWithRefreshToken: async () => {
callCount++;
await new Promise(r => setTimeout(r, delayMs));
Copy link
Copy Markdown
Contributor

@gjtorikian gjtorikian May 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this could/should use vi.useFakeTimers() + vi.advanceTimersByTimeAsync()

nicknisi and others added 2 commits May 14, 2026 13:55
Co-authored-by: Garen Torikian <gjtorikian@users.noreply.github.com>
- Make bug report template framework-agnostic (remove hardcoded
  Next.js/TanStack references)
- Use vi.useFakeTimers() + vi.advanceTimersByTimeAsync() in dedup
  tests instead of real setTimeout delays
@nicknisi nicknisi merged commit 00550ad into main May 14, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants