Kvika Team·· 7 min read

OAuth Token Refresh in a Concurrent SaaS

Multiple tabs, webhooks, cron, and user clicks hitting the same expired Microsoft or Google token — proactive refresh, fire-and-forget listeners, and reconnect UX when invalid_grant wins.

oauthsecurityengineeringgooglemicrosoft-outlook

Integration broke is the worst class of bug. Mail looks empty. Calendar freezes. No modal explains why. Logs show invalid_grant or expired access token from three hours ago. The user blames your product; the provider revoked or rotated credentials quietly.

OAuth refresh in a SaaS with webhooks, polling, background extraction, and multi-tab users is not a single code path — it is a concurrency problem dressed as auth. This is how we handle it today and where it still hurts.

Silent empty mail

Microsoft access tokens expire in about an hour without refresh. Google clients can refresh mid-request via library callbacks. If refresh fails and you return an empty list instead of an error state, users think they have no mail — not that they need to reconnect.

Proactive refresh

Before Graph or Gmail API calls, check integration.expiresAt. If now ≥ expiry, refresh synchronously in that request path, update the Integration row, proceed with the new access token.

Reactive-only refresh (wait for 401) feels fine until two requests hit the same expired token simultaneously — both refresh, both write, last write wins, refresh token rotation can invalidate the sibling session.

Ideal: single-flight refresh per user+provider with a short distributed lock. Minimum viable: one canonical refresh path that others await or re-read from DB after.

Concurrent refresh races

Tab A and tab B both load the inbox. Both see expiry. Both POST to Microsoft token endpoint. Both update Integration. Intermittent auth failures follow — especially when Microsoft rotates refresh tokens on each use.

We have not shipped full single-flight everywhere yet. Documenting the race helped support recognise the pattern: works after reload, fails again under parallel load.

Google token listeners

typescript
oauth2Client.on('tokens', (tokens) => {
  void (async () => {
    try {
      if (tokens.access_token || tokens.refresh_token)
        await prisma.integration.update({ ... })
    } catch (e) {
      console.error('Failed to persist refreshed Gmail tokens', e)
    }
  })()
})

Google's client emits tokens mid-flight. Fire-and-forget persistence is convenient; silent failure means memory has fresh tokens while DB has stale ones. Log persistence errors loudly — do not swallow.

needsReauth, not generic 401

Map refresh failure to { needsReauth: true } in JSON responses. UI shows reconnect on the integration card. Generic 401 sends users in circles through settings.

Watch for env var inconsistency — some routes use MICROSOFT_CLIENT_ID, others AZURE_AD_CLIENT_ID. Refresh works in mail but fails in directory lookup; debugging that without a scope matrix wastes days.

Related: unified inbox · calendar OAuth. Join the Kvika beta.

Kvika Team

Kvika unifies your calendar, email, and tasks across Google and Microsoft. Join the beta at kvika.work/waitlist.