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
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.comand itssharing/restendpoint, plus the master WebMap item explicitlyhttps://services8.arcgis.com/X84q166Srnyl4JMV/arcgis/rest/services— every hosted feature servicehttps://us4-iot.arcgis.comand…/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.