Skip to main content

The Token Handshake

The WebMap is private. So is the camera feature service, so are the Velocity streams, so is the portal item that holds the topographic style. Every request the SDK makes to those needs a credential, and the SDK makes its first requests the instant a MapView exists. Render the map one tick before a token is registered and the WebMap's layer-metadata probes fail with ArcGIS error 499, which the SDK does not retry. That constraint shapes everything on this page: the token has to be in hand before the map element renders, on every page load, in every tab, for as long as the tab lives.

The house rule from the security chapter applies in full. The browser never holds the client secret; it holds a short-lived application token minted by our own server.

One token for everyone, minted once​

The grant is client_credentials, which means the token identifies the application, not the operator. It is therefore identical for every visitor, and it would be wasteful to mint it per request. lib/server/arcgisToken.ts keeps a process-wide cache: it asks for a 720-minute token (twelve hours), starts a new mint once the cached one is within ten minutes of expiry, lets a page's HTML wait at most 800 ms for a mint before shipping without it, and backs off for 30 s after a failure so no page is delayed by a broken OAuth endpoint.

Two details are easy to miss. A refresh-due token that is still valid is served immediately while the replacement mints in the background; only a missing token makes a request wait. And concurrent callers share one in-flight mint, so a burst of page loads after a deploy costs one OAuth round trip, not one per load.

The credentials themselves come from pages/api/env.ts: Azure Key Vault secrets ESRIClientId and ESRISecret under NODE_ENV=production (via DefaultAzureCredential, so managed identity in Azure and the Azure CLI locally), or ARCGIS_CLIENT_ID and ARCGIS_CLIENT_SECRET from .env.local everywhere else. They are fetched once per server instance and held in memory.

Getting it to the browser before the map​

The token's life, on a clockRun the clock, open tabs at different moments, then flip the switch to a client that trusts the nominal 12 h.

00:00Token #1 has 720 min left. A tab opening now inherits exactly that, and schedules its first refetch at 715 min — not at 12 h.

The fast path is the server bootstrap. lib/server/appBootstrap.ts fetches the token alongside the feature flags and session, bounded by the 800 ms wait, and the root layout inlines the result. RootLayoutClient seeds it into React Query under QUERY_KEYS.ARCGIS_TOKEN before any child mounts, so the first render of useArcGISToken() already has data and the map can mount on first paint. Before this existed the map could not start until a browser → Front Door → BFF → ArcGIS round trip completed, which measured around 400 ms at best.

The slow path is GET /api/arcgis/token, answered from the same cache with Cache-Control: no-store. It exists for tabs whose bootstrap timed out and, more importantly, for refreshes.

Refreshing in a tab left open overnight​

queries/useArcGISToken.ts schedules refetches from the expiresIn the server actually returned, not from the nominal twelve hours: the server may hand out a token that is already hours old. Both staleTime and refetchInterval are (expiresIn − 300 s), floored at 60 s; refetchIntervalInBackground is on so a hidden tab still refreshes; focus and reconnect refetches are off because the interval already covers them; failures retry three times with exponential backoff capped at 30 s.

Every refetch flows into useArcGISAuth, which re-registers the new token for each server. Since registerToken overwrites the credential for the same prefix, a refresh is idempotent and a tab can run for days.

Registration, and the whitelist​

hooks/useArcGISAuth.ts is the only code that touches IdentityManager. On every token change it calls registerToken({ server, token, expires }) for each URL in ARCGIS_AUTHENTICATED_SERVER_URLS (data/constants.ts). Passing expires lets the SDK age the credential in step with the real token. Matching is by prefix, so the hosted-services root covers every FeatureServer beneath it. The list today:

  • https://www.arcgis.com and its sharing/rest endpoint, plus the master WebMap item explicitly
  • https://services8.arcgis.com/X84q166Srnyl4JMV/arcgis/rest/services — every hosted feature service
  • https://us4-iot.arcgis.com and …/standard/x84q166srnyl4jmv — the Velocity stream servers

The hook returns isReady, which flips once the first registration completes, and every component that renders an <arcgis-map> gates on it. FeatureMap does; so does the small alert-area map in MapPolygonFL, which calls the same hook for the same reason.

Unknown servers fail fast, on purpose​

The WebMap bundles layers from portals we have no credentials for, egis.fire.ca.gov among them. Left alone, the SDK answers each challenge by opening its browser sign-in dialog, which an operator can neither satisfy nor usefully dismiss. So, once, at first registration, suppressUnknownCredentialChallenges wraps IdentityManager.getCredential: a URL that does not prefix-match the whitelist is rejected immediately with a logged warning, and whitelisted URLs fall through untouched.

The consequence to remember is that a layer we cannot authenticate disappears silently. useFeatureMapAGOLLayers.removeFailedLayers then evicts it from the WebMap if its load error looks like a 401, 403 or token problem. When a layer you expect is simply absent, the console line "Suppressed credential challenge for unknown server" is the first thing to look for, and the fix is to add the FeatureServer base URL (no trailing layer index) to the whitelist.

Streams do not read IdentityManager​

StreamLayer connects over wss://, and WebSocket upgrades bypass the SDK's request pipeline, so a registered credential never reaches them. The token has to be placed on the layer as a custom parameter, which the SDK appends to the connection URL as ?token=…:

layer.customParameters = { ...layer.customParameters, token };

This happens twice, deliberately. usePreparedWebMap injects it into every stream the catalog knows about, including parked ones, before the view exists, so a stream restored from the Layers Menu an hour later already carries it. The view-ready handler repeats the injection over listWebMapLayers(webmap), which catches a token that refreshed between the two moments. A stream whose token is stale fails to connect with no dialog and no error toast; the Layers Menu row simply never finishes loading.

The interceptor's part​

The request interceptor you met on the previous page is the other half of this handshake. The SDK's first request to a secured layer is an unauthenticated probe; the 499 it gets back is what prompts IdentityManager to look up the registered credential and retry. Swallowing that error in the interceptor looks like a clean-up and is in fact a way to leave every secured layer unauthenticated forever. The interceptor logs auth errors and rethrows them.

Minting a new credential pair​

In ArcGIS Online: Organization → Settings → Security → OAuth 2.0 → Add, and choose the Server application type, because the client-credentials grant is only offered to that type. Store the pair in each environment's Key Vault under the two secret names above, then restart the App Service: credentials are cached per instance for the process lifetime, and a changed secret is not re-read until then.

Wrapping up​

One application token, minted on the server with a twelve-hour life and a ten-minute refresh buffer, inlined into the HTML so the map mounts on first paint, refreshed by React Query from the real remaining lifetime, registered by prefix with IdentityManager, and copied by hand onto every StreamLayer because WebSockets never ask the identity manager. Unknown servers are refused fast rather than prompting, so a missing layer is almost always a whitelist gap.

Next: One WebMap, One View, One Lifetime, what happens the moment isReady turns true.