Skip to content

Authenticating a browser app

This guide is for developers building a client-side web application (React, Vue, Svelte, ArcGIS Maps SDK, MapLibre, vanilla JS — it doesn’t matter) that needs to read and edit data through Sync Server’s feature service endpoints.

The recommended path is the @imaps/sync-server-auth package: zero dependencies, works with any framework or none, and loads straight from a CDN for static HTML sites. Everything it does is plain HTTP — a popup, one postMessage, and a bearer token — and the protocol appendix documents that for anyone who would rather hand-roll it.


Sync Server signs users in on its own domain and then hands your app a short-lived app token — a signed JWT that carries the user’s identity and the organizations they belong to. Your app attaches that token to every data request.

┌────────────┐ 1. open popup ┌──────────────────┐
│ your app │ ───────────────────► │ Sync Server │
│ (any host) │ │ sign-in page │
└────────────┘ └──────────────────┘
▲ │
│ 3. postMessage({ token, expires, │ 2. user authenticates with
│ user }) │ whatever provider(s) the
└──────────────────────────────────────┘ org has enabled
│
│ 4. every data request carries the token
▼
GET /api/v0/rest/services/{base}/{table}/FeatureServer/0/query?token=…
POST …/applyEdits (Authorization: Bearer … also works)

Why a popup and not a redirect? Because your app keeps its state — no full page reload, no need to restore a map view or an unsaved form after the round trip. It is also the only flow that works for an app on a different origin than Sync Server; see Why not a redirect?.

Identity providers are configured per deployment by the Sync Server administrator (email magic link, Microsoft Entra ID, ArcGIS, Airtable, NocoDB, and others). Your app does not need to know or care which are enabled — the sign-in page presents whatever is available.

No callback page. The popup completes on a page Sync Server hosts, so your app ships no callback.html and needs no route for it.


Ask your Sync Server administrator for:

Item Example Notes
Server URL https://sync.example.com The origin your app will talk to.
Base ID 42 or appXXXXXXXXXXXXXX The dataset (“base”) you’re editing.
Table name Facilities The layer you’re editing.
Your app’s origin https://maps.example.com Must be allowed for CORS. Localhost origins for development usually need to be added too.

With a bundler:

Terminal window
npm i @imaps/sync-server-auth

Without one — a static HTML/CSS/JS site — load it from the npm CDN. Either form works; the classic script also creates a ready-to-use client for you:

<!-- classic script: exposes window.SyncServerAuth and creates SyncServerAuth.auth -->
<script src="https://cdn.jsdelivr.net/npm/@imaps/sync-server-auth@alpha/dist/sync-server-auth.iife.js"
data-server="https://sync.example.com"></script>
<!-- or as an ES module -->
<script type="module">
import { createSyncAuth } from 'https://cdn.jsdelivr.net/npm/@imaps/sync-server-auth@alpha/dist/index.js';
const auth = createSyncAuth({ server: 'https://sync.example.com' });
</script>

Use the plain dist/ file paths as shown, not jsDelivr’s /+esm bundler.

All three entry points export the same createSyncAuth(options). The platform-specific ones return a client with the map-library wiring already done.

Import What you get
@imaps/sync-server-auth Core client. Auto-wires the ArcGIS SDK when it is loaded from the CDN.
@imaps/sync-server-auth/arcgis Client pre-wired into IdentityManager and a request interceptor for npm @arcgis/core. See ArcGIS Maps SDK.
@imaps/sync-server-auth/maplibre Client with transformRequest and refreshSource for MapLibre / Mapbox GL. See MapLibre.

In the script-tag build, add data-platform="arcgis" or data-platform="maplibre" to the <script> tag, or call SyncServerAuth.arcgis.createSyncAuth(...) / SyncServerAuth.maplibre.createSyncAuth(...) yourself.


import { createSyncAuth, SignInError } from '@imaps/sync-server-auth';
const auth = createSyncAuth({ server: 'https://sync.example.com' });
signInButton.onclick = async () => {
try {
await auth.signIn();
} catch (err) {
if (err instanceof SignInError && err.code === 'cancelled') return; // user closed the popup
showError(err.message); // 'popup-blocked', 'server-error', 'invalid-response'
}
};
auth.subscribe((session) => {
app.hidden = !session;
who.textContent = session ? session.user.name ?? session.user.email : '';
});

Points worth knowing:

  • Call signIn() from a real user gesture (a click). Popups opened from useEffect, timers, or promise callbacks are blocked by most browsers.
  • Persistence is on by default. The session is stored in localStorage (storage: 'session' or 'memory' to change that) and restored when createSyncAuth runs, so a page reload skips the popup. Expired entries are dropped automatically, and tabs stay in sync.
  • Concurrent calls share one popup. signIn(), ensureSignedIn() and reauthenticate() are single-flight, so a burst of failed requests never opens a burst of popups.
  • auth.session is { token, expires, user }; expires is epoch milliseconds. Tokens are short-lived (on the order of hours) and cannot be refreshed silently — the package re-runs the popup when needed. The browser still holds the Sync Server session cookie, so a re-sign-in during the same session is typically a flash of the popup with no typing.

With data-server on the script tag, a client already exists at SyncServerAuth.auth, and two data attributes cover the common UI without any JavaScript:

<button data-sync-auth="signin" data-sync-auth-when="signed-out">Sign in</button>
<button data-sync-auth="signout" data-sync-auth-when="signed-in">Sign out</button>
<main data-sync-auth-when="signed-in">…</main>
<script>
const { auth } = SyncServerAuth;
auth.subscribe(async (session) => {
if (!session) return;
const res = await auth.fetch(auth.geojsonUrl(42, 'Facilities'));
render(await res.json());
});
</script>
const auth = createSyncAuth({ server: SERVER }); // module scope, one per server
function useSession() {
return React.useSyncExternalStore(auth.subscribe, auth.getSnapshot, auth.getServerSnapshot);
}
function Gate({ children }) {
const session = useSession();
if (!session) return <button onClick={() => auth.signIn()}>Sign in</button>;
return children;
}

auth.subscribe(cb) fires on every change and returns an unsubscribe function; auth.session is the current value. That is all a shallowRef, a Svelte readable, or a nanostores atom needs.


Feature services live at:

{SERVER}/api/v0/rest/services/{baseId}/{tableName}/FeatureServer

with the usual ArcGIS-style operations (/0/query, /0/applyEdits, /0/addFeatures, /0/updateFeatures, /0/deleteFeatures, …). The client builds these URLs for you and auth.fetch handles the token: it signs in if there is no session, attaches Authorization: Bearer, and on a 401, 403 or ArcGIS 499 re-authenticates once and retries.

Reading:

const params = new URLSearchParams({ where: '1=1', outFields: '*', f: 'json' });
const res = await auth.fetch(`${auth.featureServerUrl(baseId, table)}/0/query?${params}`);
const { features } = await res.json();

Editing (applyEdits takes form-encoded values, as ArcGIS services do):

const body = new URLSearchParams({
f: 'json',
updates: JSON.stringify([{ attributes: { OBJECTID: 17, status: 'Active' } }]),
});
const res = await auth.fetch(`${auth.featureServerUrl(baseId, table)}/0/applyEdits`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
const result = await res.json();
// Per-feature outcomes: result.updateResults[i].success / .error

Pass { interactive: false } to auth.fetch when a popup would be inappropriate (background refresh, non-user-initiated work); it then rejects with a SignInError of code not-signed-in instead of opening one.

Other endpoints on the same token, if you’d rather not speak ArcGIS:

  • GET|POST|PATCH|DELETE {SERVER}/api/v0/bases/{baseId}/tables/{tableName}/records[/{id}] — plain JSON CRUD.
  • auth.geojsonUrl(baseId, table) → {SERVER}/api/v0/{baseId}/{table}.geojson — whole-table GeoJSON.
  • auth.vectorTileServerUrl(baseId, table) → …/VectorTileServer — Mapbox Vector Tiles.

Sync Server accepts the token three ways, which matters when something other than auth.fetch makes the request:

Where How
Header Authorization: Bearer {token}
Query string ?token={token} — auth.withToken(url) adds it
Form body token={token} (POST)

MapLibre and Mapbox GL have no notion of an identity manager, so the token is attached with transformRequest. The /maplibre entry point ships one that matches your Sync Server origin, adds the token, and leaves everything else — basemap, fonts, third-party tiles — untouched.

import { createSyncAuth } from '@imaps/sync-server-auth/maplibre';
const auth = createSyncAuth({ server: SERVER });
await auth.ensureSignedIn(); // transformRequest is synchronous — sign in before the map loads
const map = new maplibregl.Map({
container: 'map',
style: 'https://your-basemap-style.json',
transformRequest: auth.transformRequest,
});

auth.transformRequestWithHeader sends Authorization: Bearer instead of ?token=. Note that headers force MapLibre to fetch through XHR rather than the browser’s native image/tile path and make requests subject to CORS preflight; the query-parameter form is usually the simpler choice for tiles.

Option A — GeoJSON source (small to medium layers)

Section titled “Option A — GeoJSON source (small to medium layers)”

The query operation speaks GeoJSON directly (f=geojson), so a feature service layer can be a geojson source with no conversion step:

const params = new URLSearchParams({ where: '1=1', outFields: '*', outSR: '4326', f: 'geojson' });
map.addSource('facilities', {
type: 'geojson',
data: `${auth.featureServerUrl(baseId, table)}/0/query?${params}`,
});
map.addLayer({
id: 'facilities-circles',
type: 'circle',
source: 'facilities',
paint: { 'circle-radius': 5, 'circle-color': '#c1272d' },
});

auth.geojsonUrl(baseId, table) is a whole-table shortcut that skips the query parameters. Both load the full result set into browser memory, so they suit thousands of features, not millions. Add resultRecordCount / resultOffset to page, or a where clause to narrow the set server-side.

For datasets too big to hold in memory, point a vector source at the VectorTileServer, which serves standard Mapbox Vector Tiles:

map.addSource('parcels', {
type: 'vector',
tiles: [`${auth.vectorTileServerUrl(baseId, table)}/{z}/{x}/{y}.pbf`],
minzoom: 0,
maxzoom: 14,
});
map.addLayer({
id: 'parcels-fill',
type: 'fill',
source: 'parcels',
'source-layer': table, // see note below
paint: { 'fill-color': '#3d7ea6', 'fill-opacity': 0.4 },
});

source-layer must match the layer name inside the tiles, which normally tracks the table name. Don’t guess it — read it from the server’s style resource:

GET {SERVER}/api/v0/rest/services/{baseId}/{table}/VectorTileServer/resources/styles/root.json

The layers[].source-layer value there is authoritative. Service metadata lives at .../VectorTileServer and .../VectorTileServer/tiles.json.

MapLibre and Mapbox GL are rendering libraries — they draw sources, they don’t write to them. Edits go through the same applyEdits call shown in Use the feature service, followed by a refresh of whatever source displays the data:

await auth.fetch(`${auth.featureServerUrl(baseId, table)}/0/applyEdits`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
auth.refreshSource(map.getSource('facilities')); // geojson: re-fetched; vector: tiles cache-busted

Vector tiles are cached both by the server and by the browser, so an edit won’t appear until they’re re-requested; refreshSource varies the tile URLs to force that. It is also how you recover after a re-authentication. Frequent, immediately-visible edits are generally better served by a GeoJSON source; tiles are the right call for large, mostly-static reference layers.

If the layer is styled server-side, resources/styles/root.json is a complete MapLibre style document for the vector tile source — you can pass it as the map’s style, or merge its layers into your own style, instead of hand-writing paint properties.


SDK from the CDN (<script src="https://js.arcgis.com/5.1/">, including map components): nothing to do. The core client detects the SDK’s $arcgis.import global, registers the token with IdentityManager, and installs a request interceptor that attaches it to every request the SDK makes to Sync Server. Pass arcgis: false to createSyncAuth to opt out.

SDK from npm (@arcgis/core through a bundler): import from the /arcgis entry point instead.

import { createSyncAuth } from '@imaps/sync-server-auth/arcgis';
const auth = createSyncAuth({ server: SERVER });
// FeatureLayers pointed at Sync Server now carry the token, and a 401/403/499 re-opens the popup once.

@arcgis/core is an optional peer dependency and is imported for you. If your bundler can’t resolve it, pass the modules in via arcgis: { esriConfig, esriId }. connectArcgis(auth) is also exported for wiring an existing client; both are idempotent per client, so mixing them is safe.

Sync Server advertises token-based security via /api/v0/rest/info, so the SDK knows a token is required. Its generateToken endpoint exists for protocol compatibility only — app tokens come from the sign-in flow above, not from generateToken. If you inspect SDK errors yourself, note that ArcGIS clients receive 499 with a JSON error body instead of a bare 401; look at err.details.httpStatus.


await auth.signOut();

This clears the local session first — that’s what actually revokes your app’s access — then makes a best-effort call to end the Sync Server session so the next popup prompts for credentials again. Already-issued app tokens remain valid until they expire; signing out does not retroactively invalidate them. Keep token lifetimes in mind if that matters for your deployment.


The popup is the default because it leaves your app running and resolves a promise. Some environments forbid it — kiosks, strict enterprise browser policy, embedded webviews. signIn() handles that by default (mode: 'auto'): when the popup is blocked it falls back to a full-page redirect. You can also ask for it outright:

await auth.signIn({ mode: 'redirect' }); // or createSyncAuth({ server, mode: 'redirect' })

The browser goes to the sign-in page and comes back to returnTo (default: the current page) with the session in the URL fragment — #imaps_auth=…. The fragment never reaches a server; createSyncAuth reads it on load, stores the session, and removes it from the address bar and history. Because the page unloads, the promise never settles: react to auth.subscribe(...) or check auth.isSignedIn on load instead of .then().

The same mechanism rescues the magic link opened in a different tab: the popup’s completion page has no opener to report to, so it redirects to your app instead.

Your app’s origin must be on the server’s redirect allow-list. The Sync Server administrator adds it to authRedirectOrigins in the server config (or AUTH_REDIRECT_ORIGINS in the environment). Unlike CORS, * is not accepted — this redirect carries a bearer token, so an open list would be an open redirector.


Appendix: the protocol without the package

Section titled “Appendix: the protocol without the package”

If you can’t or don’t want to use @imaps/sync-server-auth, this is everything it does. Two endpoints carry the sign-in:

  • GET {SERVER}/api/v0/auth/signin?callbackUrl={URL} — the sign-in page.
  • GET {SERVER}/auth/popup-complete[?returnTo={URL}] — a page hosted by Sync Server that, once the session cookie exists, mints an app token and postMessages it to window.opener, then closes itself. With no opener and a returnTo, it forwards to the endpoint below.
  • GET {SERVER}/api/v0/auth/complete?returnTo={URL} — redirect completion: 302s to {URL}#imaps_auth=<base64url JSON of { token, expires, user }> when {URL}’s origin is allow-listed, otherwise explains why not. Without a session it first bounces through the sign-in page.

Open the first with the second as callbackUrl, and listen for the message:

export function signIn(SERVER) {
return new Promise((resolve, reject) => {
const callbackUrl = encodeURIComponent(`${SERVER}/auth/popup-complete`);
const popup = window.open(`${SERVER}/api/v0/auth/signin?callbackUrl=${callbackUrl}`, 'sync-auth', 'width=520,height=640');
if (!popup) return reject(new Error('Popup blocked — allow popups for this site.'));
const onMessage = (event) => {
if (event.origin !== new URL(SERVER).origin) return; // trust the server origin only
if (event.data?.type === 'imaps:auth') {
cleanup();
resolve(event.data); // { token, expires, user }
} else if (event.data?.type === 'imaps:auth:error') {
cleanup();
reject(new Error(event.data.error ?? 'Sign-in failed'));
}
};
const poll = setInterval(() => {
if (popup.closed) { cleanup(); reject(new Error('Sign-in cancelled')); }
}, 500);
const cleanup = () => { clearInterval(poll); window.removeEventListener('message', onMessage); };
window.addEventListener('message', onMessage);
});
}

The message payload:

{
"type": "imaps:auth",
"token": "eyJhbGciOiJIUzI1NiJ9…",
"expires": 1767225600000,
"user": { "id": "…", "name": "…", "email": "…", "image": null }
}

Rules the package follows that yours should too:

  • Always pass an absolute callbackUrl. Relative values can be resolved against an internal address behind a reverse proxy.
  • Always check event.origin. window.addEventListener('message') receives messages from anyone.
  • Treat 401, 403 and 499 as “the token is no longer good”: discard it and sign in again, single-flight, so a burst of failures opens one popup.
  • Persist with an expiry check and drop stale entries.
  • Sign out by clearing your copy, then GET /api/v0/auth/csrf and POST /api/v0/auth/signout with the csrfToken and credentials: 'include', best-effort.

The popup flow is for interactive users. For a backend service, a scheduled job, or a public read-only map with no sign-in, use an API key instead — a long-lived ims_… credential issued in the Sync Server UI, scoped to an org or a single base, optionally read-only, and restrictable by origin and IP.

API keys go in the same three places as app tokens (Authorization: Bearer, ?token=, or a form field). Read-only keys are rejected on write operations (applyEdits, addFeatures, updateFeatures, deleteFeatures, attachment edits) with a 403.


Symptom Likely cause
Popup never opens (SignInError code popup-blocked) Not called from a user gesture, or the browser blocked it. Surface an “allow popups and retry” message.
Popup completes, no session appears The server option doesn’t match the origin the popup was served from (check for www., ports, or http/https mismatches).
Sign-in works, data requests 401 The user isn’t a member of the org that owns the base.
499 Token Required from an ArcGIS layer No token reached the request — the SDK loaded from npm without the /arcgis entry point, or the layer URL isn’t on the server origin.
MapLibre tiles load without a token transformRequest wasn’t passed, or the map was created before ensureSignedIn() resolved and the source hasn’t been refreshed.
CORS errors in the console Your app’s origin isn’t allowed on that deployment. Ask your administrator to add it (including your dev origin).
Works locally, fails deployed Server URL still pointing at localhost, or the production origin wasn’t added to the allow list.
applyEdits returns 200 but nothing changed Check updateResults[i].error — the row-level failure is inside the body.