[{"data":1,"prerenderedAt":342},["ShallowReactive",2],{"site":3,"writing-\u002Fwriting\u002Fyour-token-cache-key-has-to-know-about-your-deployments":50,"writing-nav":329},{"id":4,"bookingLabel":5,"bookingUrl":6,"company":7,"contact":8,"extension":11,"hire":12,"legal":15,"location":16,"meta":17,"name":18,"nav":19,"owner":38,"social":39,"status":46,"stem":47,"tagline":48,"__hash__":49},"site\u002Fsite.yml","Book a call for what's next","https:\u002F\u002Fcal.com\u002Fanthive\u002Fintro","Ant Hive d.o.o.",{"heading":9,"intro":10},"Start with a call.","Thirty minutes, no slides. Tell me what you're building and when you need someone.","yml",{"heading":13,"intro":14},"If you're here to hire","Start with the problem you have. I'll tell you straight whether I'm the right person for it.","Ant Hive d.o.o., Braće Radića 41, Solin, Croatia. OIB 87072518531. MBS 05985889, Trgovački sud u Splitu. Share capital € 2.500, paid in full. Director: Ante Borzić.","Split, Croatia, CET",{},"Ant Hive",[20,23,26,29,32,35],{"label":21,"to":22},"Writing","\u002Fwriting",{"label":24,"to":25},"Work with me","\u002Fwork-with-me",{"label":27,"to":28},"On my desk","\u002Fdesk",{"label":30,"to":31},"About","\u002Fabout",{"label":33,"to":34},"Colophon","\u002Fcolophon",{"label":36,"to":37},"Contact","\u002Fcontact","Ante Borzić",[40,43],{"label":41,"to":42},"LinkedIn","https:\u002F\u002Fwww.linkedin.com\u002Fin\u002Fante-borzic",{"label":44,"to":45},"GitHub","https:\u002F\u002Fgithub.com\u002Fb0rza","On a long-term contract.","site","Writing code is the easy part now. Musings and work from Ante Borzić, on the rest.","2Xoi9K5fmdspVs2Iabkuc-XV6mG1EfiZBm3RojZ-9K0",{"id":51,"title":52,"body":53,"date":315,"description":316,"draft":317,"extension":318,"headline":319,"meta":323,"minutes":183,"navigation":223,"path":324,"seo":325,"stem":326,"topic":327,"__hash__":328},"writing\u002Fwriting\u002Fyour-token-cache-key-has-to-know-about-your-deployments.md","Your token cache key has to know about your deployments",{"type":54,"value":55,"toc":309},"minimark",[56,60,63,68,71,74,77,89,92,96,102,105,266,269,273,276,279,283,286,302,305],[57,58,59],"p",{},"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.",[57,61,62],{},"That worked for months. Then a new stage came online.",[64,65,67],"h2",{"id":66},"two-stages-one-redis-one-key","Two stages, one Redis, one key",[57,69,70],{},"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.",[57,72,73],{},"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.",[57,75,76],{},"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.",[57,78,79,80,84,85,88],{},"The key was the same on every stage: ",[81,82,83],"code",{},"salesforce:token",", with ",[81,86,87],{},"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.",[57,90,91],{},"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.",[64,93,95],{"id":94},"the-fix-is-a-prefix","The fix is a prefix",[97,98,99],"sidenote",{},[57,100,101],{},"The lock key has to follow the token key. Change only the token key and the stages stop overwriting each other, but still wait on each other's refresh lock.",[57,103,104],{},"Put whatever makes the token different into a prefix, and build both keys from it:",[106,107,112],"pre",{"className":108,"code":109,"language":110,"meta":111,"style":111},"language-ts shiki shiki-themes github-light github-dark","function cacheKeys(integration: string, scope: string) {\n  const prefix = `${integration}:oauth:${scope}`\n  return { token: `${prefix}:token`, lock: `${prefix}:lock` }\n}\n\nconst emarsys = cacheKeys('emarsys', config.clientId)\nconst salesforce = cacheKeys('salesforce', config.stage)\n","ts","",[81,113,114,155,181,212,218,225,246],{"__ignoreMap":111},[115,116,119,123,127,131,135,138,142,145,148,150,152],"span",{"class":117,"line":118},"line",1,[115,120,122],{"class":121},"szBVR","function",[115,124,126],{"class":125},"sScJk"," cacheKeys",[115,128,130],{"class":129},"sVt8B","(",[115,132,134],{"class":133},"s4XuR","integration",[115,136,137],{"class":121},":",[115,139,141],{"class":140},"sj4cs"," string",[115,143,144],{"class":129},", ",[115,146,147],{"class":133},"scope",[115,149,137],{"class":121},[115,151,141],{"class":140},[115,153,154],{"class":129},") {\n",[115,156,158,161,164,167,171,173,176,178],{"class":117,"line":157},2,[115,159,160],{"class":121},"  const",[115,162,163],{"class":140}," prefix",[115,165,166],{"class":121}," =",[115,168,170],{"class":169},"sZZnC"," `${",[115,172,134],{"class":129},[115,174,175],{"class":169},"}:oauth:${",[115,177,147],{"class":129},[115,179,180],{"class":169},"}`\n",[115,182,184,187,190,193,196,199,202,204,206,209],{"class":117,"line":183},3,[115,185,186],{"class":121},"  return",[115,188,189],{"class":129}," { token: ",[115,191,192],{"class":169},"`${",[115,194,195],{"class":129},"prefix",[115,197,198],{"class":169},"}:token`",[115,200,201],{"class":129},", lock: ",[115,203,192],{"class":169},[115,205,195],{"class":129},[115,207,208],{"class":169},"}:lock`",[115,210,211],{"class":129}," }\n",[115,213,215],{"class":117,"line":214},4,[115,216,217],{"class":129},"}\n",[115,219,221],{"class":117,"line":220},5,[115,222,224],{"emptyLinePlaceholder":223},true,"\n",[115,226,228,231,234,236,238,240,243],{"class":117,"line":227},6,[115,229,230],{"class":121},"const",[115,232,233],{"class":140}," emarsys",[115,235,166],{"class":121},[115,237,126],{"class":125},[115,239,130],{"class":129},[115,241,242],{"class":169},"'emarsys'",[115,244,245],{"class":129},", config.clientId)\n",[115,247,249,251,254,256,258,260,263],{"class":117,"line":248},7,[115,250,230],{"class":121},[115,252,253],{"class":140}," salesforce",[115,255,166],{"class":121},[115,257,126],{"class":125},[115,259,130],{"class":129},[115,261,262],{"class":169},"'salesforce'",[115,264,265],{"class":129},", config.stage)\n",[57,267,268],{},"The two integrations get different scopes on purpose.",[64,270,272],{"id":271},"the-key-needs-what-makes-the-token","The key needs what makes the token",[57,274,275],{},"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.",[57,277,278],{},"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.",[64,280,282],{"id":281},"start-from-the-deployment-map","Start from the deployment map",[57,284,285],{},"The code was right until the deployment changed, so the check starts with the deployments:",[287,288,289,293,296,299],"ul",{},[290,291,292],"li",{},"List every process that can reach the store: each stage, preview deploys, a laptop pointed at the shared Redis for debugging.",[290,294,295],{},"Next to each one, write down which credentials it uses.",[290,297,298],{},"Anything that differs between two of them goes in the key.",[290,300,301],{},"Do it again whenever a stage is added. That's when this broke.",[57,303,304],{},"And make sharing a Redis a decision. If two stages share one, someone should have said so, and the key should show it.",[306,307,308],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":111,"searchDepth":157,"depth":157,"links":310},[311,312,313,314],{"id":66,"depth":157,"text":67},{"id":94,"depth":157,"text":95},{"id":271,"depth":157,"text":272},{"id":281,"depth":157,"text":282},"2026-10-23","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.",false,"md",[320,321,322],"Your token cache key","> has to know about","your deployments",{},"\u002Fwriting\u002Fyour-token-cache-key-has-to-know-about-your-deployments",{"title":52,"description":316},"writing\u002Fyour-token-cache-key-has-to-know-about-your-deployments","Backend","1DVCX02n3T4UKCh3O0wcNHf6W7i2cfnqIGSJ7TneVb8",[330,334,335,338],{"path":331,"title":332,"topic":333},"\u002Fwriting\u002Fhow-to-audit-a-system-with-an-ai-agent-that-never-gets-the-keys","How to audit a system with an AI agent that never gets the keys","Security",{"path":324,"title":52,"topic":327},{"path":336,"title":337,"topic":333},"\u002Fwriting\u002Fan-install-policy-for-the-year-of-npm-worms","An install policy for the year of npm worms",{"path":339,"title":340,"topic":341},"\u002Fwriting\u002Fwhy-our-purchase-event-waited-for-the-page-to-load","Why our purchase event waited for the page to load","Analytics",1793359541022]