Integrations
Graph token cache poisoning: vier uur Teams-agent plat
Een tenant admin roteerde één Entra-secret om 16:47 op vrijdag, en een Gents kantoor met 28 mensen verloor hun Teams-agent voor vier uur. Hier is hoe het ging.

16:47, een vrijdag eind mei. De IT-lead van een accountancy met 28 mensen in Gent roteerde een client secret op een Entra ID-app-registratie als onderdeel van zijn kwartaal-hygiëne. Om 16:51 stopte de Teams-agent van het kantoor, degene die klant-e-mails sorteert in Outlook-mappen en dagelijkse intake-samenvattingen plaatst in een Teams-kanaal, met reageren. Om 17:30 stonden er acht berichten in de partner-groepchat met de vraag waarom 'de bot' kapot was. Om 20:55 was hij terug. De vier uur ertussen gingen op aan het uitpluizen van een MSAL token cache die prima tevreden was met een credential die niemand anders in de tenant kon zien, en de post die we wilden opschrijven voordat we het zouden vergeten.
Dit was onze build, acht maanden eerder uitgerold op een stack die we goed kennen. De fout zat niet in de code die we schreven. Hij zat in de aanname dat het roteren van één client secret een operatie met laag risico is.
De setup
De agent is een .NET 8 worker, gehost op een Azure App Service Premium V3. Hij gebruikt MSAL.NET met de confidential client flow tegen Microsoft Graph, gescopet op Mail.ReadWrite, ChannelMessage.Send en Chat.Read. Twee instances draaien achter het App Service-plan voor redundantie. De MSAL distributed token cache hangt aan een Azure Cache for Redis-instance zodat beide workers dezelfde tokens zien en geen throttle-budget verspillen aan duplicaten.
De app-registratie in Entra ID had twee actieve client secrets, gestaggerd met zes maanden. Standaard patroon: één in gebruik, één in opwarming. De IT-lead roteerde de oudere omdat zijn kalenderherinnering dat zei.
Wat het roteren van de secret eigenlijk deed
In de Entra-portal is het verwijderen van een client secret één klik, gevolgd door een bevestiging. Microsoft documenteert de operatie als direct. Er is geen propagation-vertraging, geen soft-delete, geen recovery-window. De secret is niet langer geldig op het moment dat de API-call terugkomt.
Dit is wat we aannamen dat zou gebeuren. De agent draait met de nieuwere secret, opgeslagen in Azure Key Vault. De oudere secret die verwijderd wordt zou een no-op moeten zijn, want niets verwijst ernaar.
Dit is wat er echt gebeurde. De agent was drie weken eerder opnieuw gedeployed met de oudere secret als actieve credential, nadat een eerdere rotatie scheefging en iemand (wij) beide secrets in App Configuration had laten staan met de oudere als 'primary'. De nieuwere secret zat in Key Vault, gemarkeerd als actief, maar het worker-proces had hem nooit opnieuw ingelezen. Dus de worker authenticeerde met een secret die de tenant admin zojuist had verwijderd, terwijl het dashboard in Entra suggereerde dat de nieuwere secret in gebruik was.
De lijst 'actieve credentials' van een app-registratie in Entra vertelt je welke secrets bestaan. Hij vertelt je niet welke secret je draaiende proces daadwerkelijk meestuurt in zijn token-requests. Dat zijn twee verschillende feiten.
De token cache failure mode
Hier werd het interessant, en hier kwamen die vier uur vandaan.
MSAL.NET cachet access tokens agressief. De confidential client flow geeft een token terug met een levensduur van 60 tot 90 minuten. Zolang dat token binnen zijn levensduur valt, geeft MSAL hem uit zonder terug te gaan naar Entra. Microsofts eigen MSAL-richtlijn is expliciet: roep AcquireTokenForClient niet aan in een loop met de verwachting verse tokens te krijgen, want je krijgt de gecachte tot hij verloopt.
Dus om 16:47, toen de secret werd verwijderd, hadden beide worker-instances een geldig gecacht access token in Redis staan. Het token was gemint met de inmiddels verwijderde secret, maar het token zelf is een signed JWT en Entra kan hem niet met terugwerkende kracht ongeldig verklaren. Graph accepteerde het token zonder problemen. De agent bleef werken.
Ongeveer twaalf minuten lang.
Toen liep de expiry-klok van het token af. MSAL ging refreshen. De refresh-call stuurde de (verwijderde) client secret mee. Entra gaf AADSTS7000215: Invalid client secret provided terug. MSAL ving de error op en, conform policy, ruimde de slechte entry uit de cache en gooide de exception door naar onze code.
Onze worker logde de error naar Application Insights, gooide een ServiceException, en de App Service health probe (die Graph-bereikbaarheid checkt als onderdeel van zijn readiness-logica) markeerde de instance als unhealthy. App Service rolde de instance. De nieuwe instance kwam op, las dezelfde configuratie, probeerde dezelfde authenticatie, faalde op dezelfde manier, en werd unhealthy gemarkeerd. Idem voor de tweede instance. Binnen tien minuten had het App Service-plan beide workers vier keer gecycled en zat het in een flapping state.
De Redis-cache maakte het flappen erger, niet beter. Elke nieuwe instance trok dezelfde stale cache-state binnen, retried de auth-call met dezelfde dode secret, en verbrandde dezelfde exception. Het distribueren van de cache had ons in normaal bedrijf throttle-ruimte opgeleverd. Het had ook een single-instance failure veranderd in een gesynchroniseerde.
Het vier uur durende debug-pad
De on-call engineer (één van ons) werd om 17:08 gepiept. Hier is de echte debug-route, want elke stap zag eruit als de voor de hand liggende oorzaak en alleen de laatste klopte.
17:08 tot 17:35. Het is de deploy.
Application Insights toonde dat de failure om 16:51 begon. Het eerste instinct was een slechte deploy, want er was die dag om 14:00 een feature branch gemerged. We rolden terug naar de vorige container image. Zelfde failure. Geëlimineerd.
17:35 tot 18:20. Het is Graph.
De error-code AADSTS7000215 klinkt als een Graph-zijdig probleem. De AADSTS error reference lijst hem als 'invalid client secret provided'. We namen aan dat Entra het mis had, omdat de secret in Key Vault de juiste was en Key Vault dat ook zei. We openden een Microsoft support-ticket vanuit de verkeerde framing: 'Graph weigert een geldige secret'. Veertig minuten verloren aan een hypothese die niemand bij Microsoft ging valideren.
18:20 tot 19:40. Het is de cache.
We flushten de Redis MSAL-cache, herstartten beide instances, en zagen dezelfde error terugkomen. Dat was correct gedrag, maar het vertelde ons dat de cache een symptoom was en niet de oorzaak. Daarna dumpten we de daadwerkelijke secret-waarde die de worker verstuurde, via een diagnostische build achter een feature flag die we precies hiervoor bewaren, en vergeleken hem met de twee secrets die in Entra stonden. De worker stuurde de secret die Entra liet zien als 'twee uur geleden verwijderd'.
19:40 tot 20:55. Het is de configuratie.
We traceerden de secret terug naar App Configuration, dat via een Key Vault-reference inlas. De reference klopte. De opgehaalde waarde was de verwijderde secret. De Key Vault-secret had twee versies, en de worker zat via URI gepind op een oudere versie. We hadden die URI zes maanden geleden gezet tijdens de vorige rotatie, en vergeten de versie-pin te verwijderen toen we de nieuwe secret als actief markeerden.
De fix was vijf regels. Vervang dit:
// pinned to a specific version, never drifts
appSettings: {
GraphClientSecret: '@Microsoft.KeyVault(SecretUri=https://kv-ghent.vault.azure.net/secrets/graph-client-secret/9c4f...e2a1)'
}Door dit:
// no version, always returns the current secret
appSettings: {
GraphClientSecret: '@Microsoft.KeyVault(SecretUri=https://kv-ghent.vault.azure.net/secrets/graph-client-secret/)'
}Redeploy. Tokens netjes uitgegeven. Teams-agent terug online om 20:55.
Wat we wijzigden aan ons deployment-patroon
Drie structurele veranderingen kwamen hieruit voort. Ze zijn saai. Dat is het punt.
Eén: Key Vault-references pinnen nooit een versie. De versie-gepinde URI was de root cause. Pinnen is geschikt voor een onveranderlijk artefact, zoals een certificaat dat je nog niet wilt verlengen. Het is fout voor een credential die je van plan bent te roteren. De ongepinde URI geeft altijd de huidige secret terug. Wil je veilige rollouts, doe ze dan op secret-niveau, niet op URI-niveau.
Twee: de worker logt welke secret-thumbprint hij net heeft gebruikt, bij elke token-acquisition. Niet de secret zelf. De eerste zes tekens van een SHA-256 hash van de secret. Die ene logregel had drie uur van dit incident gescheeld, want we hadden in één oogopslag gezien dat de worker een thumbprint gebruikte die niet meer bestond in Entra.
var thumbprint = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(secret))
)[..6];
_logger.LogInformation(
"Acquired Graph token using secret {Thumbprint}",
thumbprint
);Drie: de health probe van de agent maakt onderscheid tussen 'Graph is down' en 'we kunnen niet authenticeren'. De oude probe gaf 503 bij elke Graph-failure, en daardoor flapte het App Service-plan. De nieuwe probe geeft 200 bij een auth-failure, maar stuurt een critical alert en pauzeert de task loop van de worker. De redenering: een auth-failure betekent dat onze config fout zit. Het cyclen van de container repareert onze config niet. Stop met cyclen. Piep een mens.
De credential-hygiëne kant die niemand leuk vindt
Nog één ding. De week dat we dit zaten te debuggen stond op de Hacker News-voorpagina een verhaal over open source-tools van Microsoft die werden misbruikt om credentials van AI-developers te stelen. De root cause daar is anders dan die van ons, maar de les rijmt. Elke credential die een AI-agent raakt heeft een grotere blast radius dan een mens-only systeem, want de agent draait continu, houdt tokens langer vast, en wordt doorgaans gedeployed met minder observability dan een customer-facing API. Een secret-rotatie die voor een webapp een minuut afleiding is, wordt voor een agent een uitval van vier uur, want er zit niemand voor de agent te kijken naar zijn falen.
Als je een Teams-agent draait, een Outlook-agent, of iets anders dat namens een tenant met Microsoft Graph praat, doe vandaag de vijf-minuten audit. Open je Key Vault secret references. Check of er één een versie pint. Zo ja, herschrijf ze zodat ze meedrijven met de huidige versie. Check daarna of je auth-failure pad daadwerkelijk een mens piept, in plaats van een container cyclen tot de tenant admin merkt dat de bot stil is.
Toen we de Teams-agent voor dit Gentse kantoor bouwden, was de aanname die ons brak dat Entra en onze deployment-stack het eens zouden zijn over welke secret de actieve was. Dat waren ze niet. We hebben ons AI-agent-deployment-patroon herbouwd rond die les, en de versie-pin regel staat nu in onze projecttemplate. Loop je tegen hetzelfde flap-gedrag aan op een Graph-gestuurde agent, dan zit de fix bijna altijd in de Key Vault URI, niet in je code.
Vijf minuten vandaag: open één Key Vault-reference URI op je agent en check of hij eindigt op een version hash. Zo ja, verwijder de hash en redeploy.
Kern
Het pinnen van een Key Vault secret URI aan een versie verandert een routine credential-rotatie in een uitval van uren. Verwijs naar de laatste versie, niet naar een specifieke.
FAQ
Wat is token cache poisoning in deze context?
Het is wanneer een MSAL token cache tokens blijft serveren (of probeert te refreshen) die gemint zijn met een credential die niet meer bestaat. De cache is prima; de upstream secret is verwijderd, en elke refresh-poging faalt op dezelfde manier.
Waarom breekt het roteren van één client secret de agent als de andere secret nog geldig is?
Omdat het draaiende proces niet per se de secret gebruikt die Entra als actief toont. Als je Key Vault-reference is gepind op een specifieke versie, blijft je worker de oude secret meesturen, ongeacht wat de Entra-portal laat zien.
Hoe check ik of mijn Graph-integratie hieraan blootstaat?
Open elke Key Vault-reference die je worker gebruikt voor client secrets of certificaten. Eindigt de SecretUri op een version hash, dan sta je bloot. Herschrijf de URI zonder versie, zodat hij altijd de huidige secret teruggeeft.
Moet de App Service health probe Graph überhaupt aanroepen?
Alleen als je auth-failures kunt onderscheiden van service-failures. Een container cyclen lost een tijdelijke Graph-uitval op. Het lost een credential-mismatch niet op, en proberen verandert één verouderde secret in een gesynchroniseerde flap over al je instances.