Back

Guide

OAuth Token Refresh for Multi-Tenant Integrations

October 21, 20268 min read

Short answer: when your SaaS calls a partner's API on behalf of many customers, each customer has its own tokens, and each needs refreshing forever. Refresh at about 80% of the token's lifetime instead of waiting for a 401, allow only one refresh at a time per tenant, save a rotated refresh token before you use anything else, pass and check the tenant id on every call, and when a refresh fails permanently, mark the connection as needing re-authorisation and tell the customer, instead of retrying forever.

A founder who had built a two-way sync with a partner put token refresh first on the list of where the work actually went. That is typical. The first OAuth flow is a demo that works; the bugs arrive weeks later, when tokens expire under load, two workers refresh at once, or a customer revokes access on a Sunday. This guide covers the five rules that prevent those, with code you can hand to your own coding agent.

What is different about OAuth in a multi-tenant integration?

In a single-user app there is one token. In a multi-tenant integration, every customer who connects the partner gives you a separate grant: their own access token, their own refresh token, their own expiry, and often their own account or organisation id on the partner's side. Some partners add a second layer, an app-level token for your integration as a whole, alongside the per-customer ones. You are running hundreds of small credential lifecycles in parallel, and a bug in any of them is a support ticket from a specific customer.

Store each grant as a row, encrypted at rest:

create table partner_connections (
  tenant_id            uuid        not null,
  provider             text        not null,
  external_account_id  text        not null, -- the partner's id for this customer
  access_token_enc     bytea       not null,
  refresh_token_enc    bytea,
  issued_at            timestamptz not null,
  expires_at           timestamptz not null,
  status               text        not null default 'active'
                       check (status in ('active', 'needs_reauth', 'revoked')),
  last_error           text,
  primary key (tenant_id, provider)
);

The client secret for your OAuth app belongs in your secrets manager, never in this table. The tokens belong in this table, encrypted with a key that is also not in this table.

Rule 1: when should you refresh an access token?

At about 80% of its lifetime. The token response tells you expires_in; record when it was issued and compute the refresh point from that:

function shouldRefresh(c: Connection, now = Date.now()): boolean {
  const lifetime = c.expiresAt - c.issuedAt;
  return now >= c.issuedAt + lifetime * 0.8;
}

Refreshing on a 401 instead feels simpler and is worse. Every expiry becomes a failed request in production, the refresh happens at the moment of highest concurrency (every in-flight call fails together), and if the refresh endpoint is slow or down you have no valid token at all. Refreshing early buys you the last 20% of the lifetime as slack: a transient refresh failure is retried while the current token still works, and the customer never notices.

Keep the 401 path as a fallback, not the plan: on a 401, force one refresh and retry the call once. Clock skew, early revocation and partners that shorten lifetimes without telling you all end up there.

Rule 2: how do you avoid a refresh token race condition?

Make refresh single-flight per tenant. Without it, twenty parallel requests for one customer all notice the token is due, and all twenty call the refresh endpoint. At best that is nineteen wasted calls and a rate-limit hit. With rotating refresh tokens (rule 3), it is worse: the first refresh invalidates the token the other nineteen are using.

Two layers. Inside one process, share one in-flight promise per tenant:

const inflight = new Map<string, Promise<string>>();

function singleFlight(key: string, fn: () => Promise<string>): Promise<string> {
  const existing = inflight.get(key);
  if (existing) return existing;
  const p = fn().finally(() => inflight.delete(key));
  inflight.set(key, p);
  return p;
}

async function getAccessToken(tenantId: string): Promise<string> {
  const c = await connections.get(tenantId, "partner");
  if (c.status !== "active") throw new NeedsReauth(tenantId);
  if (!shouldRefresh(c)) return decrypt(c.accessTokenEnc);
  return singleFlight(tenantId, () => refreshUnderLock(tenantId));
}

Across processes (several workers, several serverless instances), take a lock keyed on the tenant, and re-read the row after acquiring it, because the worker that held the lock before you may already have refreshed:

async function refreshUnderLock(tenantId: string): Promise<string> {
  const outcome = await db.transaction(async (tx) => {
    await tx.query("select pg_advisory_xact_lock(hashtext($1))", ["oauth:" + tenantId]);

    const c = await tx.connections.get(tenantId, "partner"); // re-read under the lock
    if (c.status !== "active") return { kind: "needs_reauth" as const };
    if (!shouldRefresh(c)) return { kind: "ok" as const, token: decrypt(c.accessTokenEnc) };

    const res = await refreshAtProvider(decrypt(c.refreshTokenEnc));

    if (res.ok) {
      const now = Date.now();
      await tx.connections.update(tenantId, "partner", {
        accessTokenEnc: encrypt(res.accessToken),
        // rotating providers send a new refresh token; keep the old one only if none came back
        refreshTokenEnc: res.refreshToken ? encrypt(res.refreshToken) : c.refreshTokenEnc,
        issuedAt: now,
        expiresAt: now + res.expiresIn * 1000,
        lastError: null,
      });
      return { kind: "ok" as const, token: res.accessToken };
    }

    if (res.error === "invalid_grant") {
      await tx.connections.update(tenantId, "partner", {
        status: "needs_reauth",
        lastError: "invalid_grant",
      });
      return { kind: "needs_reauth" as const };
    }

    // transient: keep the current token if it is still valid, retry later with backoff
    if (Date.now() < c.expiresAt) return { kind: "ok" as const, token: decrypt(c.accessTokenEnc) };
    return { kind: "transient" as const };
  });

  if (outcome.kind === "ok") return outcome.token;
  if (outcome.kind === "needs_reauth") {
    await notifyReconnectNeeded(tenantId); // after commit, so the status is really saved
    throw new NeedsReauth(tenantId);
  }
  throw new TransientRefreshError(tenantId);
}

A transaction-scoped advisory lock releases itself on commit or rollback, so a crashed worker cannot leave a tenant locked. Hash collisions between tenants only mean two unrelated refreshes occasionally wait for each other, which is harmless. Put a short timeout on the refresh call, since you are holding a transaction open across it. A row lock (select ... for update on the connection) works just as well if you prefer it.

Rule 3: how do you handle rotating refresh tokens?

Many providers rotate refresh tokens: every refresh returns a new refresh token, and the old one stops working, sometimes after a short grace period, sometimes immediately. Rotation is a security feature, and some providers go further: if an old refresh token is presented again, they assume it was stolen and revoke the entire grant. Your race condition just became a customer who has to reconnect.

Three consequences:

  1. Single-flight is mandatory, not an optimisation. Two refreshes with the same refresh token is exactly the reuse pattern rotation is designed to punish.
  2. Persist the new refresh token before anything else. In the code above it is written in the same transaction that holds the lock. If the provider rotated the token and your write is lost, the only valid refresh token existed in memory and is now gone. Keep the window between the provider's response and the commit as small as possible, and log (without the token) when a commit fails right after a successful refresh, because the next refresh for that tenant will fail.
  3. Don't assume rotation either way. If the response contains a refresh token, store it. If it doesn't, keep the one you have. The same code then works for providers that rotate and providers that don't.

Rule 4: check the tenant id on every call

The most serious bug a multi-tenant integration can have is not an expired token. It is a valid token used for the wrong customer: customer A's action reads or writes customer B's data at the partner. It usually comes from something innocent, like a module-level currentToken variable, a cache keyed by provider instead of by tenant, or a background job that forgot to set the context.

  • No ambient credentials. Every function that calls the partner takes tenantId as an explicit argument and fetches the token itself.
  • Every cache key starts with the tenant. Tokens, partner object ids, rate-limit counters.
  • Record the partner's account id at connect time (external_account_id above) and assert it whenever a response or a webhook tells you whose data it is. Some APIs also take the tenant as a header on every request; set it from the connection row, never from request input.
  • Fail closed. On a mismatch, throw and alert. Do not "fix" it by looking up the right tenant and carrying on.
async function callPartner(tenantId: string, path: string): Promise<unknown> {
  const conn = await connections.get(tenantId, "partner");
  const res = await partnerFetch(path, await getAccessToken(tenantId));
  const body = await res.json();
  if (body.account_id && body.account_id !== conn.externalAccountId) {
    throw new TenantMismatch(tenantId, path); // log ids and path, never the payload
  }
  return body;
}

Rule 5: what to do when a token refresh fails

Split failures by whether retrying can help:

FailureMeaningWhat to do
Network error, timeout, 5xx, 429The partner is struggling, the grant is fineRetry with backoff and jitter; keep using the current token while it is valid
invalid_grantThe grant is gone: revoked, expired, rotated away, or the user leftStop. Mark the connection needs_reauth. Never retry in a loop
invalid_clientYour app's credentials are wrong or rotatedPage yourself. This affects every tenant at once

When a connection moves to needs_reauth:

  1. Stop calling the partner for that tenant. Jobs check the status first and skip.
  2. Degrade the feature visibly and calmly. Show your own message where the partner feature normally sits ("Reconnect your account to keep syncing"), not an error.
  3. Tell the person who connected it. An in-product banner for admins plus one email, with a reconnect button that starts a fresh OAuth flow.
  4. Catch up after reconnecting. Run a sync from your last high-water mark, since anything that changed while disconnected was missed.
  5. Count it. Track refresh failures per partner per day. A sudden rise means the partner changed something, and you want to know before customers write in.

A pre-launch checklist for multi-tenant OAuth

  1. Tokens stored per tenant, encrypted, with issue and expiry times.
  2. Proactive refresh at ~80% of the lifetime; one forced refresh and retry on a 401.
  3. Single-flight refresh: in-process promise plus a per-tenant lock with a re-read.
  4. New refresh tokens persisted inside the locked transaction.
  5. Tenant id explicit on every call; partner account id asserted when the API returns it.
  6. invalid_grant moves the connection to needs_reauth, notifies the customer and degrades the feature.
  7. A daily count of refresh failures per partner.

Pair this with idempotent webhook handling and you have closed the two most mechanical of the common SaaS integration problems.

Who owns this in an integration partnership?

The team whose product makes the calls owns its token lifecycle, every time. It is part of the maintenance that starts after launch, and no partner or platform should hold your customers' credentials for you; doing so would make them a single place to steal every tenant's access at once. What the partner can do is document it: token lifetime, whether refresh tokens rotate, whether reuse revokes the grant, and the per-tenant model. Ask for those five facts before you build.

Ordana works the same way: it never holds a partner credential. Members build their integrations themselves, with their own coding agent, while Ordana handles the matching, the terms and the revenue share.


Related reading: