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.
1. How it works
Section titled “1. How it works”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.
2. Before you start
Section titled “2. Before you start”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. |
3. Install
Section titled “3. Install”With a bundler:
npm i @imaps/sync-server-authWithout 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.
Pick your platform by import path
Section titled “Pick your platform by import path”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.
4. Sign the user in
Section titled “4. Sign the user in”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 fromuseEffect, 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 whencreateSyncAuthruns, so a page reload skips the popup. Expired entries are dropped automatically, and tabs stay in sync. - Concurrent calls share one popup.
signIn(),ensureSignedIn()andreauthenticate()are single-flight, so a burst of failed requests never opens a burst of popups. auth.sessionis{ token, expires, user };expiresis 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.
Plain HTML site
Section titled “Plain HTML site”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;}Vue, Svelte, anything else
Section titled “Vue, Svelte, anything else”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.
5. Use the feature service
Section titled “5. Use the feature service”Feature services live at:
{SERVER}/api/v0/rest/services/{baseId}/{tableName}/FeatureServerwith 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 / .errorPass { 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) |
6. MapLibre GL / Mapbox GL
Section titled “6. MapLibre GL / Mapbox GL”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.
Option B — vector tiles (large layers)
Section titled “Option B — vector tiles (large layers)”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.jsonThe layers[].source-layer value there is authoritative. Service metadata lives
at .../VectorTileServer and .../VectorTileServer/tiles.json.
Editing
Section titled “Editing”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-bustedVector 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.
Picking up ArcGIS symbology
Section titled “Picking up ArcGIS symbology”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.
7. ArcGIS Maps SDK for JavaScript
Section titled “7. ArcGIS Maps SDK for JavaScript”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.
8. Sign out
Section titled “8. Sign out”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.
Popups blocked? Use the redirect flow
Section titled “Popups blocked? Use the redirect flow”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 andpostMessages it towindow.opener, then closes itself. With no opener and areturnTo, 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,403and499as “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/csrfandPOST /api/v0/auth/signoutwith thecsrfTokenandcredentials: 'include', best-effort.
Appendix: server-side and machine access
Section titled “Appendix: server-side and machine access”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.
Troubleshooting
Section titled “Troubleshooting”| 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. |