In this article
August 27, 2026
August 27, 2026

Token isolation is the easy half of multi-tenant OAuth

Storage architecture is the half you can finish. The refresh loop, key rotation, and revocation are the half that never does.

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

The multi-tenant OAuth architecture post has become its own genre: pool versus silo versus bridge isolation, a token record schema, envelope encryption, a section on key rotation. The better entries in the genre concede something true along the way, which is that per-tenant token isolation has become a shared baseline rather than a differentiator.

That concession is right, and the analysis usually stops one layer too early. Storage architecture is the half of multi-tenant OAuth you can finish: pick an isolation model, wrap a data-encryption key per record with a per-tenant key-encryption key, ship it. The credential lifecycle is the half that never finishes. Refresh races. Providers that revoke an entire authorization over a duplicate request. Tokens that never tell you when they expire. Revocations that arrive from outside your system with no webhook attached. The costly incidents in this category happen after the token is already encrypted at rest.

Much of what follows comes from Nango, which published a detailed account of operating auth across more than 900 APIs. It is the most specific public writeup of this problem, and it is cited throughout.

Isolation is a decision you make once

The storage layer has converged for a reason: the good answer is known. Pool storage keeps every tenant's tokens in one table under one key with tenant boundaries enforced by a query predicate; silo gives each tenant its own store and key; bridge shares infrastructure but puts a per-tenant cryptographic boundary on top. Bridge is the interesting one, because it silently degrades into pool if the per-tenant key boundary is enforced in application code instead of at the IAM or encryption-context level.

Envelope encryption is what makes bridge hold. A fresh DEK encrypts each record, a per-tenant KEK wraps the DEK, and the KEK never leaves the HSM in plaintext. In WorkOS Vault, the tenant boundary is expressed as key context on the write, and each unique name-value pair in that context maps to its own KEK, created just in time:

  
const object = await workos.vault.createObject({
  name: 'acme-stripe-key',
  value: 'sk_live_...',
  context: { organizationId: 'org_acmecorp' },
});
  

The data key for that object is then wrapped by every KEK in the context. Two consequences follow that are easy to miss at design time: the number of KEKs grows with the number of distinct contexts rather than the number of objects, and there is a cap, 500 unique KEKs per environment by default. Partition on long-lived boundaries like an organization or tenant. Per-record identifiers exhaust the limit.

That's a schema decision and a library call. It buys a bounded blast radius and nothing more. Cryptographic isolation does not stop an attacker holding a valid application credential that the API layer considers authorized, because that caller gets plaintext back, correctly. Isolation is a floor. Treating it as the interesting part of the problem is how teams end up surprised by everything above it.

The refresh loop is where this actually breaks

In May 2025, Nango merged a pull request with an if condition that never fired. Refreshed credentials stopped being written to the database. They caught it and reverted in just under two hours. In those two hours, scheduled jobs refreshed and discarded access and refresh tokens for hundreds of connections.

Most of those connections repaired themselves. As Nango put it, "for most APIs, that was recoverable: the old refresh token still worked, so the next scheduled refresh repaired the connection." The exception is the whole story. For providers that rotate refresh tokens, the discarded token was the only one that worked, and every affected connection meant asking a user to go through the consent screen again.

Airtable and Atlassian invalidate the old refresh token when a replacement is issued; Salesforce and Slack do the same when rotation is enabled on the OAuth app. Rotation is a spec requirement, not a vendor quirk. RFC 9700, published as BCP 240 in January 2025, is explicit that "refresh tokens for public clients MUST be sender-constrained or use refresh token rotation." Section 4.14.2 requires authorization servers to "utilize one of these methods to detect refresh token replay by malicious actors for public clients," and describes the second of the two like this:

"Refresh token rotation: the authorization server issues a new refresh token with every access token refresh response. The previous refresh token is invalidated, but information about the relationship is retained by the authorization server. If a refresh token is compromised and subsequently used by both the attacker and the legitimate client, one of them will present an invalidated refresh token, which will inform the authorization server of the breach. The authorization server cannot determine which party submitted the invalid refresh token, but it will revoke the active refresh token. This stops the attack at the cost of forcing the legitimate client to obtain a fresh authorization grant."

Flow diagram of a refresh race. Two workers refresh the same connection. The authorization server issues a new token and invalidates the old one, so the second request arrives holding a dead token. That splits into two possible causes shown in identical boxes, your concurrency bug and a stolen refresh token, which converge on a single box reading "identical from the server's side." The chain ends in a highlighted box: grant revoked, your user gets a reconnect prompt.
The two middle boxes are drawn the same because they are the same, as far as the authorization server can tell. It resolves the ambiguity in the only safe direction.

Read that from the client's side. The authorization server cannot tell your concurrency bug apart from a stolen token, so it resolves the ambiguity in the only safe direction and kills the grant. Two workers refreshing the same connection in the same instant produce exactly the signature the spec is designed to catch, and the penalty lands on your user, in your product's UI, as a reconnect prompt you get to explain.

The refresh path is a distributed systems problem wearing an OAuth costume. Three disciplines fall out of it:

Serialize refreshes per connection. One refresh in flight per connection, behind a distributed lock, is what Nango settled on after enough of these.

Persist before you proceed. Nango's phrasing is that they now treat refresh tokens like money, not cache: the new token is persisted before anything else can fail, that path has its own regression tests, and platform-level refresh anomalies page the on-call team.

Make the retry window a per-provider fact. Atlassian documents a 10-minute reuse interval on rotating refresh tokens, and says plainly that it exists so "breach detection features don't apply when exchanging a refresh token multiple times," to "avoid network concurrency issues." Airtable, by contrast, returns a 409 on a refresh that conflicts with a recent one, documents no reuse window at all, and warns that "frequent invalid refresh requests may result in token revocation." Xero lets you retry with the previous refresh token for up to 30 minutes if the response never arrives. Same operation, three different blast radii.

Refreshing early is its own trap. Per Nango's integration notes, Exact Online access tokens live 10 minutes and refreshes are only accepted in the final 30 seconds of validity, so a global "refresh 15 minutes early" rule fails every time until per-provider expiration buffers exist. Airtable, meanwhile, invalidates the old access token the instant a refresh succeeds, so a long-running job holding a token from thirty seconds ago can fail mid-run.

You cannot trust expires_in

RFC 6749 marks expires_in as RECOMMENDED rather than required, and adds that a server which omits it "SHOULD provide the expiration time via other means or document the default value." Providers use that latitude freely, and the other means vary.

Provider Behavior your refresh logic has to encode
Salesforce Token response includes no expiry; the documented check is a separate introspection endpoint
Zendesk Now applies default TTLs automatically, 30 minutes for access tokens and 30 days for refresh tokens, on a published enforcement schedule; expires_in survives as an override, not as the switch that turns expiry on
Zoho CRM Reported to return HTTP 200 with the error in the body; the docs list the error codes but never the status
NetSuite Refresh tokens expire after 7 days for confidential clients, 2 days by default for public clients, and expiry requires full re-consent
Google Refresh tokens live indefinitely, except for external-user-type projects in Testing status, which get 7 days; after six months unused; or past 100 live refresh tokens per client for a user, which evicts the oldest rather than revoking

Auth failures also do not always look like failures, which is Nango's own framing for the lesson. No generic client gets this right, which is why they ended up declaring an authenticated probe endpoint per provider:

"We ended up building per-provider heuristics: 276 of the roughly 935 providers in our registry now declare an authenticated endpoint that Nango can call to check whether credentials still work."

Two other consequences are worth budgeting for. Tokens die of inactivity, so credentials have to be exercised on a schedule rather than only on demand. And provider auth changes ship without a feed to subscribe to. Nango reports that 16% of commits to their provider config are fixes, and that "there's no reliable feed for provider auth changes; we often learn about them from a failed flow or a customer report." That number is the honest per-provider maintenance cost of a catalog, whoever owns it.

Rotating your keys should never cost a consent screen

"Key rotation" names two unrelated operations, and conflating them turns a security improvement into a support queue.

Two side-by-side panels comparing key rotations. The left panel, "the provider's rotation," which you don't control, shows three stacked layers all highlighted in the same warning color: refresh token, rotated by the provider; the grant, revoked on a bad refresh; your user, sees a consent screen. The right panel, "your rotation," invisible to users, shows key-encrypting key, you rotate it, and data keys, re-wrapped, both highlighted, above a third layer in plain gray: ciphertext, untouched.
Same word, two operations. On the left the damage reaches the bottom layer. On the right it stops at the top.

The provider's rotation is the refresh token exchange above. You don't control it, and mishandling it costs re-consent. Note that it can also be a one-way door: Slack's token rotation cannot be turned off once enabled, and turning it on changes access tokens from non-expiring to a 12-hour lifetime. Salesforce ISV partners are required to enable it and cannot disable it afterward.

Your rotation is the encryption key protecting the stored credential, and it should be invisible to users. Envelope encryption is what makes that true: because a KEK wraps DEKs rather than data, moving to a different KEK means re-encrypting DEKs while the ciphertext stays where it is. A key_id/key_version on each record lets an application-managed key change re-wrap lazily, on next read or via a background sweep, with old and new versions coexisting. Routine automatic KMS rotation needs no rewrap at all: AWS keeps the key ID stable, retains the old backing material internally, and states outright that rotation "does not rotate the data keys that the KMS key generated or re-encrypt any data protected by the KMS key." Worth remembering what that means: rotating the KEK is a control-plane action, not a cryptographic refresh of the data underneath it.

The failure mode to design against is a concurrent write during rotation. WorkOS Vault's updateObject takes a versionCheck parameter for optimistic locking, so the write fails rather than silently overwriting when another process has updated the object since your read. That is the same discipline the refresh path needs, applied to the storage layer.

It's also worth checking what your token cache does when nobody is looking. Microsoft's MSAL distributed token cache is not encrypted at rest by default: Encrypt defaults to false, and the MSAL.NET cache serialization sample still ships options.Encrypt = false, though the newer Microsoft.Identity.Web guidance now shows it enabled and lists encryption at rest as a best practice. A KEK hierarchy above a plaintext Redis partition protects nothing.

Revocation arrives from outside your system

Nothing in your architecture generates most revocations. A user disconnects your app in the provider's settings. A password changes. An admin removes a seat. RFC 9700 says authorization servers "MAY revoke refresh tokens automatically in case of a security event, such as password change or logout at the authorization server," and that refresh tokens "SHOULD expire if the client has been inactive for some time." Airtable will revoke a whole authorization in response to repeated invalid refresh attempts. None of these arrive as a webhook you subscribed to; they arrive as a failed refresh, sometimes as an HTTP 200.

So the credential store needs opinions the schema posts skip. Separate a transient provider outage from a real revocation, because the first calls for a retry and the second calls for a reconnect prompt in your UI. Record why a credential died. Return an error the calling application can branch on rather than an exception it has to parse. Pipes, for instance, returns active: false with an error of needs_reauthorization or not_installed, which is enough to branch on, and hands you the authorization URL from a separate call when you decide to send the user back through.

Agents raise the stakes on all of it. RFC 9700 notes that refresh tokens are attractive targets precisely because "they represent the full scope of access granted to a certain client, and they are not further constrained to a specific resource." Give one to an agent and the agent can keep reading a user's Google Drive or writing to their Salesforce org for weeks after the task that justified the access finished, with no revocation event anywhere to notice. That is the gap Pipes MCP is aimed at: a reference MCP server you deploy in your own infrastructure, which puts a session boundary in front of the connection. A human approves the start of a session, tool access is revoked when it expires, and the agent cannot renew it on its own.

Where the build-versus-buy line actually sits

The argument for buying integration infrastructure is usually made one layer up: since isolation is table stakes, the real decision is the connector catalog, the actions layer, and managed sync running on top of the credential store. That framing suits a vendor selling all three, and it skips the part with the ongoing cost.

Catalogs and sync are shaped like your product. You know which providers your customers ask for, you know what your data model should look like, and normalized abstractions over other people's APIs leak in ways only you can adjudicate. Credential lifecycle is shaped like nobody's product. It's a per-provider behavioral registry, a distributed lock, an encryption hierarchy you can rotate under load, and a pager that fires when a refresh anomaly rate moves, none of which appears in a demo, on a roadmap, or in a customer conversation, right up until the week it takes down every connection you have.

That's the piece worth buying, and encryption being hard has nothing to do with it. One call to Pipes returns a token that is already valid, with the refresh scheduling, per-provider quirks, and error semantics behind it:

  
// Get a fresh access token from Pipes
const { accessToken, error } = await workos.pipes.getAccessToken({
  provider: 'github',
  userId: user.id,
  organizationId,
});

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

// Use it directly with the provider's SDK
const github = new GitHubClient(accessToken.accessToken);
const pullRequests = await github.getPullRequests();
  

If you need normalized data models across a hundred CRMs, or scheduled sync with conflict resolution, buy an integration platform that sells those. That's a real product category, and Pipes is deliberately not in it. Pipes covers the OAuth flow, storage, refresh, and provider configuration for a list that has grown past 300 providers, spanning SaaS tools, data warehouses, AI model APIs, and vertical systems.

So ask any vendor in this category, ours included, what happens when two of their workers refresh the same Airtable connection in the same millisecond. The isolation model is already on the marketing site. That answer isn't.