Rolling out required tokens on your own API: the "calls without a token" counter measures every client
When you move your own API from "token optional" to "token required", the only honest switch signal is traffic: count the calls that arrive without a token and flip the switch at zero. That counter measures the whole API, not your app, so every caller counts — the shared npm package two other solutions bundle, the direct fetch in a settings page, the "check model" button, the list-view command set that renders the same panel. Fixing the app you were working on moves the number, but it will never reach zero. Inventory callers by endpoint across all your repositories, create the token source once per app root and hand it down as a value, and give every client a test that fails on a call without the header.
Když vlastní API přepínáte z „token volitelný“ na „token povinný“, jediný poctivý signál k přepnutí je provoz: počítat volání bez tokenu a přepnout při nule. To počítadlo ale měří celé API, ne vaši appku, takže se počítá každý volající – sdílený npm balíček, který si zabalí dvě další řešení, přímý fetch ve stránce nastavení, tlačítko „ověřit model“, tlačítko v zobrazení seznamu, které vykresluje stejný panel. Oprava appky, na které zrovna pracujete, číslo sníží, ale k nule nikdy nedojde. Volající inventarizujte podle endpointu napříč všemi repozitáři, zdroj tokenu vytvořte jednou v kořeni appky a předávejte ho dolů jako hodnotu a každému klientovi dejte test, který spadne na volání bez hlavičky.
Symptom
The API is an Azure Function that your SPFx web part calls with a bearer token from AadTokenProvider. Validation ships in a soft mode: a missing token passes, an invalid one is rejected, and every few minutes the function logs how many requests came with and without a token. The plan is to flip to required once the second number is zero.
The web part sends the token, the release is verified, and the log still shows something like this (illustrative numbers):
[auth] last 5 min: with token 212, without token 37, rejected 0
Waiting does not bring the second number down: those calls come from clients nobody changed. Flipping the switch anyway would break whatever those 37 calls are — and the line does not tell you which features they belong to.
Cause
The switch was prepared per app, but the counter counts every request that reaches the API, whatever the endpoint or client. In our case the same function was called by:
| Caller | Why it had no token |
|---|---|
| A shared npm package (AI helper) bundled by two other solutions | It was ported from the original app before tokens existed and built its own fetch calls |
Direct fetch calls in one of those solutions (usage card, day summary, model check) |
Written against the endpoint, not through the service that got fixed |
| The original app's own secondary paths — a "sources" tab, a "check model" button | Direct fetch calls that bypass the service; they predate the token, and the rollout wired only the service |
| The same panel rendered by a list-view command set | The host never passed aadTokenProviderFactory, so the panel's token source stayed unconfigured |
Two details made it worse:
- The free-looking design was not free. The package's service already took the web part context as its first constructor argument, so deriving the token from it looked like a fix without touching any consumer. Reading the call sites showed every consumer passing
nullthere. Read the call sites before you build on a parameter. - A module-level singleton does not survive a bundle boundary. SPFx solutions, extension shells and runtime libraries are separate webpack bundles. Configuring a token singleton in one bundle leaves the copy in the other bundle empty — the second host keeps calling without a token, silently.
Fix
Inventory by endpoint. Grep every repository — solutions, shared packages, scripts — for the setting that holds the API address and for the endpoint paths (
/chat,/usage, …). Diagnostic calls count too.One token source per app, passed as a value. Build it once where the web part context enters your code (the web part, or the runtime library's
mount) and hand it down through a React context or constructor parameters. Services take it as a required parameter, so a new call path without it does not compile. A shared package that must stay backward compatible can keep it optional; then the test in step 3 is what catches a caller without it./** `exp` only for the cache — the API validates the signature, the client never needs to. */ function expiryOf(token: string): number { try { const p = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); return typeof p.exp === 'number' ? p.exp * 1000 : 0; } catch { return 0; } } export function createTokenHeaders(getToken: ((resource: string) => Promise<string>) | null, resource: string) { let cached: { token: string; expiresAt: number } | null = null; let inflight: Promise<Record<string, string>> | null = null; return function headers(): Promise<Record<string, string>> { if (!getToken || !resource) return Promise.resolve({}); // fail open: call as before if (cached && cached.expiresAt > Date.now() + 60000) return Promise.resolve({ Authorization: 'Bearer ' + cached.token }); if (!inflight) { // no .finally(): SPFx compiles against ES2015 promises, which lack it (TS2550) inflight = getToken(resource).then( (t): Record<string, string> => { cached = { token: t, expiresAt: expiryOf(t) || Date.now() + 300000 }; inflight = null; return { Authorization: 'Bearer ' + t }; }, (): Record<string, string> => { inflight = null; return {}; } // no consent yet: call without the header ); } return inflight; }; } // in the web part / library mount: // const headers = createTokenHeaders(r => ctx.aadTokenProviderFactory.getTokenProvider().then(p => p.getToken(r)), // '<your-api-app-id-uri>'); // e.g. api://<client-id> or api://<tenant-id>/<api-name>Guard every client with a source-level test. Find each
fetchwhose URL starts with the API address and fail when its options don't spread the token headers; find each construction of the API service and fail when it gets no token source. Plant one call without the header in the test itself to prove the detector can fail.Treat 401/403 as its own state. A card that shows "service unavailable" or "needs an update" on 401 hides exactly the problem this rollout creates.
Notes
- Keep soft mode until the counter is zero for a whole business cycle; sites still running an older client version keep sending old traffic.
- If the counter lives in instance memory, as a simple one does, every function instance logs its own line; zero has to hold on all of them.
- Related: Custom API permission request shows as invalid · Graph permission grants are tenant-wide.