When Esri Deletes Your Enterprise ArcGIS Authentication Hack: How We Migrated ArcGIS Maps SDK 2.2 to 2.3
An adventure in federated Esri Enterprise authentication, vanished native exports, and the one escape hatch we should have noticed first.
The setup
We have a Unity app that loads 3D building data from an on-premises ArcGIS Enterprise server, which is federated to a separate ArcGIS Portal. To authenticate, we use pre-generated tokens, long-lived strings that the server issues out-of-band to grant access to specific layers. Think API keys, but Enterprise-flavored.
The pattern is mundane: pass the token to the SDK, the SDK adds ?token=… to outgoing requests, the server hands back tile data. Until the SDK upgrade.
ESRI's ArcGIS Maps SDK for Unity 2.3.0 broke our entire Enterprise auth path. Not loudly, the SDK still compiled, the code still ran, and the tokens were still valid. The map just refused to load. Three months and four failed approaches later, we shipped a fix that's 100 lines of pure public API. This is the story of why it took that long, and what SDK authors and consumers should learn from it.
The audience for this post is: you write code, you know what JSON is, you've poked at FFI before, and you're tired of magic incantations. If you've ever decompiled a binary because the docs lied, you'll feel at home.
Layer 0: Why "just upgrade the SDK" hurts
The ArcGIS Maps SDK ships in five flavors: Unity, Unreal, JavaScript, Android (Kotlin), iOS, and they all wrap the same C++ engine called runtimecore (a .so/.dylib/.dll). The flavors expose different APIs but share the same underlying logic. This is great for ESRI: write the engine once, marshal it five ways. It's less great for you when the engine changes and only some of the wrappers update in sync.
In our case, version 2.3.0 of the Unity SDK shipped with a runtimecore that had 87% of its C ABI deleted. Counted in symbols: 21,460 exports in 2.2.0, 2,740 in 2.3.0. ESRI did this to reduce the SDK download size (a marketed feature) and to lock down a surface area they considered private. From their perspective, no public consumer was supposed to be touching those entry points, so removing them isn't a breaking change.
From our perspective: the load-bearing two-line hack we'd been using since 2.2.0 stopped working overnight, and ESRI didn't mention it in the release notes.
The two C functions that vanished:
RT_TokenInfo_createRT_PregeneratedTokenCredential_create
These were the only way to construct a PregeneratedTokenCredential from outside the SDK. We'd been calling them via P/Invoke + a reflection trick on an internal C# constructor, entirely a workaround, since ESRI never exposed PregeneratedTokenCredential as a public managed type in the Unity SDK to begin with. (The Android, iOS, JS, and Kotlin SDKs all expose it publicly. Unity, alone, doesn't.)
So we needed a new path.
The three approaches that didn't work
Approach 1: "Just pass the token as the layer's APIKey"
Every ESRI layer constructor takes an APIKey argument:
new ArcGIS3DObjectSceneLayer(url, name, opacity, visible, myToken);
The docs imply this should work for Enterprise 11.4+ accounts. Test it: the very first HTTP request (/rest/info) succeeds. Then the SDK detects federation, sees that the layer's host is governed by a separate portal, and issues an authentication challenge for the portal URL. That challenge has no handler, so the SDK logs:
A valid API Key or OAuth User Configuration is required for https://portal.example.com/arcgis and the layer fails to load with error code 18004 ("A token or API key is required").
The layer's APIKey is scoped to the layer's URL. It never propagates to the federation handshake. Dead end.
Approach 2: "Use the global APIKey"
ArcGISRuntimeEnvironment.APIKey is a static, process-wide setting. The docs describe it as the "default API key for all requests." Set it before any layer loads:
ArcGISRuntimeEnvironment.APIKey = myToken;
Test it. Same failure. Same challenge. Same 18004. The global key is implicitly scoped to ArcGIS Online services, it's checked when the SDK builds a request for arcgis.com, but it's not consulted when satisfying a federated Enterprise portal challenge.
Why? Because (as you'll see in a moment) authentication in the SDK isn't a property-bag lookup. It's a credential store with structural matching.
Approach 3: "Intercept the outbound HTTP request and add ?token=…"
If the SDK won't add the token, we will. The runtimecore exports a callback hook:
RT_GEHTTPRequestHandler_setRequestIssuedCallback(callback, userdata, errhandler)
This is the JavaScript SDK's "RequestInterceptor" pattern, replace the HTTP layer with your own that rewrites URLs. We wrote 250 lines of P/Invoke + UnityWebRequest plumbing, installed it, ran the test… and the callback was never invoked.
The reason hides one architectural level above HTTP. The SDK's authentication flow looks like this:
1. Layer is added
2. SDK looks up matching credentials in CredentialStore (by server-context prefix)
3. If found, attach credential to outgoing request → HTTP fires
4. If not found, raise an ArcGISAuthenticationChallenge
5. App's challenge handler responds with credential, or fails
6. If failed, error code 18004, request is NEVER ISSUED
The HTTP interceptor sits at step 3. The challenge fires at step 4. If we don't have a credential and we don't have a challenge handler, we die at step 5, the HTTP request that our interceptor would have rewritten never happens.
To make the interceptor matter, we'd first need to satisfy step 2. Which means we'd need a credential. Which is the thing we couldn't construct.
The architecture, viewed from above
Every approach above failed at the same boundary: the SDK demands a real ArcGISCredential object in its store. URL-rewriting at HTTP time can't substitute for "I have a credential for this server." The architecture is correct: authentication is a property of an identity, not a string-substitution at egress.
So the question reduces to: how do you construct an ArcGISCredential of the specific subtype PregeneratedTokenCredential when the SDK doesn't expose a public constructor for it?
Three theoretical paths:
- Call the SDK's internal constructor via reflection. (Works until ESRI marks the type sealed or strips the IntPtr ctor, fragile.)
- P/Invoke the native factory function. (Was working in 2.2.0; this is the path that broke.)
- Deserialize one from JSON.
Path 3 is the one we should have noticed first. ArcGISCredential exposes a public static method on every SDK in the family:
public static ArcGISCredential FromJSON(string json);
It calls a C function that's still in the 2.3.0 native ABI:
RT_ArcGISCredential_fromJSON(const char* json, void* errhandler);
And the C++ class Pregenerated_token_credential is still linked into the binary (you can find it with nm and a demangler). So in principle: build the right JSON, hand it to FromJSON, get back a credential object indistinguishable from one the deleted C factory would have produced.
The only missing piece is the JSON shape.
The schema, and why it's invisible
What does ESRI's native serializer emit for a PregeneratedTokenCredential? We didn't know, and ESRI doesn't document it.
The historical attempt to reverse-engineer this had been to grep the binary for JSON-looking field names:
strings runtimecore.dylib | grep -iE 'token|credential|serverContext'
That surfaces some names, serverContext, token, tokenInfo, but not the discriminator field, the one that tells the deserializer "this JSON should rehydrate as a PregeneratedTokenCredential and not as an OAuthUserCredential."
Here's the trick: the discriminator is "type", and its value is 2. An integer. No string literal called "PregeneratedTokenCredential" appears anywhere in the binary because the enum value is stored as a tagged int, not as a string name. strings will never find it.
You can't reverse-engineer a number that doesn't appear in your output.
What you can do is dynamic capture. We had two copies of the project on disk: one running 2.2.0 (where credential construction still works) and one running 2.3.0 (where we want to ship). On the 2.2.0 install, we wrote a one-shot script: construct a PregeneratedTokenCredential the working way, immediately call .ToJSON() on it, log the result.
var cred = PregeneratedTokenCredentialInterop.Create(
serviceUri: "https://example.com/arcgis",
accessToken: "FAKE_TOKEN_FOR_SCHEMA_CAPTURE",
expirationDate: DateTimeOffset.UtcNow.AddDays(30),
referer: "https://example.com",
isSSLRequired: true);
Debug.Log(cred.ToJSON());
Output:
{
"type": 2,
"serverContext": "https://example.com/arcgis",
"referer": "https://example.com",
"tokenInfo": {
"expires": 1781831585776,
"ssl": true,
"token": "FAKE_TOKEN_FOR_SCHEMA_CAPTURE"
}
}
That's the entire schema. Five top-level keys, of which referer is omitted when empty. The field names are short (ssl, token, expires), too short to stand out in a strings dump. The expiration is unix milliseconds. The token is the literal token.
This was the moment the whole problem dissolved. The schema is tiny and stable; ESRI has shipped it unchanged across multiple SDK versions, because it's the wire format for cross-SDK credential persistence (every SDK family can deserialize credentials written by every other).
The factory, in ~100 lines
Once you have the schema, the implementation is mechanical:
public static class PregeneratedTokenCredentialFactory {
// ArcGISCredentialType.PregeneratedTokenCredential = 2
private
const int TypePregeneratedTokenCredential = 2;
private static readonly JsonWriterOptions WriterOptions = new() {
// Match the native serializer's compact output.
Indented = false,
};
public static ArcGISCredential Create(string serviceUri, string accessToken, DateTimeOffset expirationDate, string referer = "", bool isSSLRequired = true) {
if (string.IsNullOrEmpty(serviceUri)) throw new ArgumentException("Service URI is required.", nameof(serviceUri));
if (string.IsNullOrEmpty(accessToken)) throw new ArgumentException("Access token is required.", nameof(accessToken));
var json = BuildJson(serviceUri, accessToken, expirationDate, referer, isSSLRequired);
return ArcGISCredential.FromJSON(json) ??
throw new InvalidOperationException("FromJSON returned null; credential JSON shape may have drifted from the captured schema.");
}
private static string BuildJson(string uri, string token, DateTimeOffset exp, string referer, bool ssl) {
using
var buffer = new ArrayBufferWriter < byte > (256);
using(var writer = new Utf8JsonWriter(buffer, WriterOptions)) {
writer.WriteStartObject();
writer.WriteNumber("type", TypePregeneratedTokenCredential);
writer.WriteString("serverContext", uri);
// Match the native serializer: omit `referer` when empty.
if (!string.IsNullOrEmpty(referer)) writer.WriteString("referer", referer);
writer.WriteStartObject("tokenInfo");
writer.WriteNumber("expires", exp.ToUnixTimeMilliseconds());
writer.WriteBoolean("ssl", ssl);
writer.WriteString("token", token);
writer.WriteEndObject();
writer.WriteEndObject();
}
return Encoding.UTF8.GetString(buffer.WrittenSpan);
}
}
That's the whole thing. No reflection, no P/Invoke, no internal-API access. To register it for an Enterprise + federated portal setup:
var store = ArcGISRuntimeEnvironment.AuthenticationManager.CredentialStore;
store.Add(PregeneratedTokenCredentialFactory.Create("https://enterprise.example.com/arcgis", token, expiry));
store.Add(PregeneratedTokenCredentialFactory.Create("https://portal.example.com/arcgis", token, expiry));
We verified the factory's output matches the native serializer's output byte-for-byte (modulo a sub-millisecond expires drift since the two credentials are constructed microseconds apart). The SDK accepts them identically.
Why does this work the same way in every ArcGIS SDK
This isn't a Unity-only trick. Every SDK in the family wraps the same runtimecore and exposes the same FromJson/fromJson/FromJSON method on ArcGISCredential:
- Android (Kotlin): ArcGISCredential.Companion.fromJsonOrNull(json)
- iOS (Swift): ArcGISCredential.fromJSON(_:)
- JavaScript: IdentityManager uses a different model, but the underlying REST format is the same JSON
- .NET (Desktop Runtime): Credential.FromJson(string)
- Flutter (Dart): ArcGISCredential.fromJsonOrNull(json)
- Unity: ArcGISCredential.FromJSON(string)
All of them deserialize the same JSON shape, because the deserializer is C++ code shared across all SDKs. Capture once, replay everywhere.
This has a deeper implication for any cross-SDK toolkit author: when one SDK doesn't expose a constructor that another SDK exposes, JSON round-tripping is often the lowest-friction bridge. We've now seen ESRI's authors use this themselves; their default CredentialPersistence implementation across all platforms (per their migration docs) uses ToJson/FromJson for cross-version compatibility.
The methodology: capture-then-port
The general pattern this fits:
- You have a working version of the system (old SDK, working production install, etc.).
- The new version removes the entry point you've been using.
- The new version still exposes a serialization API (toJson, serialize, clone, etc.).
- Capture the output of the working version's serializer.
- Replay the captured representation in the new version.
The cost is one extra hop: instead of constructing an object directly, you build a serialized form and let the new SDK deserialize it. The benefit is enormous: serialization formats tend to be more stable than code surfaces, because they're often the wire format the SDK uses for IPC, disk persistence, or cross-version interop.
When a vendor deletes a constructor, look for a deserializer. They usually leave one in.
Lessons
1. ESRI removes parts of the C ABI between minor versions without warning. The 2.3.0 release notes don't mention the 87% C-ABI reduction. If you're consuming the SDK via P/Invoke (or reflection on internal types), your code can break silently on any minor upgrade. The release-notes section that did mention RefreshTokenExchangeInterval defaults applied to OAuth, not to anything we touched.
2. The official auth path doesn't exist for federated Enterprise + pre-generated tokens. ESRI's docs imply that APIKey works for Enterprise 11.4+, but for federated deployments (where one server's auth lives at a separate portal), neither the per-layer APIKey nor the global APIKey satisfies the portal-side challenge. The only working route is a registered credential, which on Unity means FromJSON (or, before 2.3.0, the reflection hack).
3. The HTTP layer is too late. If you're considering an HTTP request interceptor as a workaround, check first whether your SDK's auth dispatch happens above or below HTTP. ESRI's runs above: no credential, no request. The interceptor never sees the traffic you want to modify.
4. strings won't surface integer discriminators. Numeric enum values stored as JSON ints are invisible to byte-grep. If you're reverse-engineering a JSON schema and the discriminator isn't appearing in strings, suspect that it's an int and try 0/1/2/… by hand. We could have skipped a week if we'd tried that earlier.
5. JSON round-trips are an underused escape hatch. When a vendor exposes both a ToJson and a FromJson on a type, you have a way to construct that type without going through the constructors, even if those constructors are private. ESRI's serializer doesn't know or care that the JSON it's reading was written by C++ code or by your hand-rolled StringBuilder. As long as the shape matches, the bytes are bytes.
What it looks like in production
Our EnterpriseCredentialRegistrar is the boring glue that figures out the federated portal URL via /rest/info, then registers two credentials, one for the SceneServer host, one for the portal, via the factory:
public static IEnumerator EnsureCredentials(string layerUrl, string token) {
var serverRoot = GetServerRoot(layerUrl);
var owningSystemUrl = await DiscoverPortalUrl(serverRoot);
var store = ArcGISRuntimeEnvironment.AuthenticationManager.CredentialStore;
store.Add(PregeneratedTokenCredentialFactory.Create(serverRoot, token, expiry));
if (!string.IsNullOrEmpty(owningSystemUrl)) {
store.Add(PregeneratedTokenCredentialFactory.Create(owningSystemUrl, token, expiry));
}
}
Then the layer is added with an empty APIKey, auth flows entirely through the credential store, exactly as ESRI's architecture intends.
The factory is the only "clever" code. The rest is what ESRI's own documentation says you should do, they just don't tell you how to construct the credential.
Closing
If you've followed along this far, you've watched four failure modes, one architectural insight, and one twelve-byte JSON token ("type": 2,) save a project. The deeper takeaway isn't about ArcGIS specifically, it's that serialization formats outlive APIs, and that the gap between "what the SDK lets you call" and "what the engine actually does" is often bridgeable by writing the bytes the engine expects.
Next time a vendor deletes your favorite constructor, look for a deserializer. It's probably still there.