Skip to content

On a long-term contract.Book a call for what's next.

Split, Croatia, CET RSS

Backend

3 min read

Your token cache key has to know about your deployments

Two stages shared a Redis nobody had decided to share, and their token refreshes overwrote each other. The cache key needs whatever makes a token different, and the deployment map tells you what that is.

Ante Borzić

The storefront server talks to two APIs with client-credentials OAuth: Salesforce and Emarsys. Fetching a token on every request is slow and runs into the token endpoint's limits, so the server caches tokens in Redis. It refreshes each one shortly before it expires, and takes a lock first so only one instance refreshes at a time.

That worked for months. Then a new stage came online.

Two stages, one Redis, one key

QA saw it during testing, and so did I: the refreshes were overwriting each other. Each stage refreshed on its own schedule and wrote its token to the same entry, replacing the other stage's.

The confusing part was that the token each stage picked up was fresh. It had just been issued, its expiry was still far off, and the cache's own check said it was good. But it had been issued for the other stage's credentials, so the API rejected the calls made with it.

The failures looked like an authentication problem on the API side. Nothing pointed at the cache, because by the cache's rules nothing was wrong: the token hadn't expired, so it was never fetched again.

The key was the same on every stage: salesforce:token, with salesforce:lock next to it. Both stages pointed at the same Redis. Nobody had decided they should share it. It was the instance the new stage happened to be configured with.

The Emarsys cache had been namespaced by client ID at first. I removed that months earlier because it was unneeded, and with one stage it was.

The fix is a prefix

Put whatever makes the token different into a prefix, and build both keys from it:

function cacheKeys(integration: string, scope: string) {
  const prefix = `${integration}:oauth:${scope}`
  return { token: `${prefix}:token`, lock: `${prefix}:lock` }
}

const emarsys = cacheKeys('emarsys', config.clientId)
const salesforce = cacheKeys('salesforce', config.stage)

The two integrations get different scopes on purpose.

The key needs what makes the token

A token is issued for a set of credentials. Two stages that use the same Emarsys client can share a token safely, because the API would give both of them an equivalent one. So the client ID is the honest key for Emarsys: stages that share credentials share a token, and stages that don't, don't. For Salesforce, the credentials differ per stage, so the stage is what tells them apart.

The question to ask is what would make the token endpoint hand you a different token. Client ID, token URL, integration user, scope: if any of them can differ between two processes that share the store, it belongs in the key.

Start from the deployment map

The code was right until the deployment changed, so the check starts with the deployments:

  • List every process that can reach the store: each stage, preview deploys, a laptop pointed at the shared Redis for debugging.
  • Next to each one, write down which credentials it uses.
  • Anything that differs between two of them goes in the key.
  • Do it again whenever a stage is added. That's when this broke.

And make sharing a Redis a decision. If two stages share one, someone should have said so, and the key should show it.