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.
Everything below is plain HTTP: a popup, one postMessage, and a bearer token.
There is no SDK to install and no framework requirement.
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. If you’d rather do a full-page redirect, the same endpoints work; see Redirect instead of popup.
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.
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. Sign the user in
Section titled “3. Sign the user in”Two endpoints do all the work:
GET {SERVER}/api/v0/auth/signin?callbackUrl={URL}— the sign-in page.GET {SERVER}/auth/popup-complete— a page hosted by Sync Server that, once the session cookie exists, mints an app token andpostMessages it towindow.opener, then closes itself.
So: open the first with the second as callbackUrl, and listen for the message.
const SERVER = 'https://sync.example.com';
export function signIn() { 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) { reject(new Error('Popup blocked — allow popups for this site.')); return; }
const onMessage = (event) => { // Only trust messages from the Sync Server origin. if (new URL(event.origin).origin !== new URL(SERVER).origin) return;
if (event.data?.type === 'imaps:auth') { cleanup(); const { token, expires, user } = event.data; resolve({ token, expires, user }); } else if (event.data?.type === 'imaps:auth:error') { cleanup(); reject(new Error(event.data.error ?? 'Sign-in failed')); } };
// The user may close the popup without finishing. 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); });}Notes worth heeding:
- 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. - Call
signIn()from a real user gesture (a click). Popups opened fromuseEffect, timers, or promise callbacks are blocked by most browsers.
What you get back
Section titled “What you get back”{ "token": "eyJhbGciOiJIUzI1NiJ9…", "expires": 1767225600000, "user": { "id": "…", "name": "…", "email": "…", "image": null }}expires is epoch milliseconds. Tokens are short-lived (on the order of
hours) and cannot be refreshed silently — when one expires, run signIn()
again. 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.
4. Persist it (optional)
Section titled “4. Persist it (optional)”Storing the token lets a page reload skip the popup. Any storage works; drop the entry once it’s expired.
const KEY = 'sync-auth';
export function saveAuth(auth) { try { localStorage.setItem(KEY, JSON.stringify(auth)); } catch {}}
export function loadAuth() { try { const auth = JSON.parse(localStorage.getItem(KEY) ?? 'null'); if (auth?.expires > Date.now()) return auth; } catch {} localStorage.removeItem(KEY); return null;}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, …). Sync Server
accepts your token three ways — pick whichever your HTTP layer makes easy:
| Where | How |
|---|---|
| Header | Authorization: Bearer {token} |
| Query string | ?token={token} |
| Form body | token={token} (POST) |
Reading:
const params = new URLSearchParams({ where: '1=1', outFields: '*', f: 'json' });
const res = await fetch( `${SERVER}/api/v0/rest/services/${baseId}/${table}/FeatureServer/0/query?${params}`, { headers: { Authorization: `Bearer ${auth.token}` } },);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 fetch( `${SERVER}/api/v0/rest/services/${baseId}/${table}/FeatureServer/0/applyEdits`, { method: 'POST', headers: { Authorization: `Bearer ${auth.token}`, 'Content-Type': 'application/x-www-form-urlencoded', }, body, },);
const result = await res.json();// Per-feature outcomes: result.updateResults[i].success / .errorOther 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.{SERVER}/api/v0/rest/services/{baseId}/{tableName}/VectorTileServer/…— vector tiles.
6. Handle expiry and rejection
Section titled “6. Handle expiry and rejection”Treat 401, 403 and ArcGIS’s 499 (“Token Required”) as the token is no
longer good: discard it and sign in again. Centralize this so a burst of failed
requests doesn’t open a burst of popups.
let pending = null;
export function reauthenticate() { pending ??= signIn().finally(() => { pending = null; }); return pending;}A generic wrapper — adapt it to whatever fetch layer you already have:
async function authedFetch(url, init = {}) { let auth = loadAuth() ?? await reauthenticate();
const call = () => fetch(url, { ...init, headers: { ...init.headers, Authorization: `Bearer ${auth.token}` }, });
let res = await call(); if (res.status === 401 || res.status === 403 || res.status === 499) { auth = await reauthenticate(); saveAuth(auth); res = await call(); } return res;}Note that ArcGIS clients receive 499 with a JSON error body instead of a bare
401; if you’re using the ArcGIS Maps SDK, inspect
err.details.httpStatus rather than a thrown status code.
7. Sign out
Section titled “7. Sign out”Clear your local copy first — that’s what actually revokes your app’s access — then best-effort clear the server session so the next popup prompts for credentials again.
export async function signOut() { localStorage.removeItem(KEY); // reset your in-memory auth state here try { await fetch(`${SERVER}/api/v0/auth/signout`, { method: 'POST', credentials: 'include', }); } catch {}}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.
Redirect instead of popup
Section titled “Redirect instead of popup”If popups are unacceptable in your environment (kiosk mode, strict enterprise policy, embedded webview), use a full-page redirect:
- Send the user to
{SERVER}/api/v0/auth/signin?callbackUrl={your app URL}. - On return, your app is same-site with the Sync Server session only if it is
hosted on the same origin. If it is,
POST /api/v0/auth/app-tokenwithcredentials: 'include'to mint the token yourself:
const res = await fetch(`${SERVER}/api/v0/auth/app-token`, { method: 'POST', credentials: 'include',});const { token, expires, user } = await res.json();Cross-origin apps should stick with the popup flow, since third-party cookie restrictions make the session cookie unreliable from another site.
Appendix: ArcGIS Maps SDK for JavaScript
Section titled “Appendix: ArcGIS Maps SDK for JavaScript”If your layers are FeatureLayers pointed at Sync Server, register the token
with the identity manager and let a request interceptor keep it fresh, instead
of hand-rolling fetch calls.
import esriConfig from '@arcgis/core/config';import esriId from '@arcgis/core/identity/IdentityManager';
// After sign-in:esriId.registerToken({ server: SERVER, token: auth.token, expires: auth.expires, userId: auth.user.id,});
// Attach the token to every request the SDK makes to Sync Server, reading// current state at request time so sign-in/out is picked up automatically.esriConfig.request.interceptors.push({ urls: SERVER, before(params) { const auth = loadAuth(); if (!auth) return; params.requestOptions ??= {}; params.requestOptions.query = { ...params.requestOptions.query, token: auth.token }; }, error(err) { const status = err?.details?.httpStatus ?? err?.httpStatus; if (status === 401 || status === 403 || status === 499) reauthenticate(); return Promise.reject(err); },});
// On sign-out:esriId.destroyCredentials();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.
Appendix: MapLibre GL JS / Mapbox GL JS
Section titled “Appendix: MapLibre GL JS / Mapbox GL JS”Esri publishes guidance for consuming ArcGIS feature services from MapLibre and
Mapbox GL JS; Sync Server’s endpoints follow the same conventions, so that
advice applies here — with one addition, since these libraries have no notion of
an identity manager: attach the token yourself with transformRequest.
Attach the token to every request
Section titled “Attach the token to every request”transformRequest is called for every URL the map loads (tiles, GeoJSON
sources, sprites, glyphs). Match your Sync Server origin and add the token;
leave everything else — basemap, fonts, third-party tiles — untouched.
import maplibregl from 'maplibre-gl'; // or mapbox-gl — identical option
const map = new maplibregl.Map({ container: 'map', style: 'https://your-basemap-style.json', transformRequest(url, resourceType) { if (!url.startsWith(SERVER)) return { url }; const auth = loadAuth(); // your stored { token, expires, user } if (!auth) return { url }; const u = new URL(url); u.searchParams.set('token', auth.token); return { url: u.toString() }; },});If you prefer headers over a query parameter, return them instead — both are accepted:
return { url, headers: { Authorization: `Bearer ${auth.token}` } };Note that headers forces MapLibre to fetch through XHR/fetch rather than the
browser’s native image/tile path, and it makes 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', // MapLibre expects WGS84 f: 'geojson',});
map.addSource('facilities', { type: 'geojson', data: `${SERVER}/api/v0/rest/services/${baseId}/${table}/FeatureServer/0/query?${params}`,});
map.addLayer({ id: 'facilities-circles', type: 'circle', source: 'facilities', paint: { 'circle-radius': 5, 'circle-color': '#c1272d' },});transformRequest adds the token to that URL like any other request. There is
also a whole-table shortcut that skips the query parameters entirely:
{SERVER}/api/v0/{baseId}/{tableName}.geojsonBoth load the full result set into browser memory, so they suit thousands of
features, not millions. Add resultRecordCount / resultOffset if you need 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: [`${SERVER}/api/v0/rest/services/${baseId}/${table}/VectorTileServer/{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 authedFetch( `${SERVER}/api/v0/rest/services/${baseId}/${table}/FeatureServer/0/applyEdits`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body },);
// GeoJSON source: re-fetch and swap the data.const fresh = await authedFetch(`${SERVER}/api/v0/${baseId}/${table}.geojson`).then((r) => r.json());map.getSource('facilities').setData(fresh);For a vector tile source, tiles are cached both by the server and by the browser, so an edit won’t appear until they’re re-requested. Vary the URL to force it:
const stamp = Date.now();map.getSource('parcels').setTiles([ `${SERVER}/api/v0/rest/services/${baseId}/${table}/VectorTileServer/{z}/{x}/{y}.pbf?v=${stamp}`,]);This is also how you recover after a re-authentication: bump the cache-busting
value (or call setData / setTiles again) so every tile is re-fetched with the
new token. 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. Esri’s own MapLibre samples do the same thing with their styles.
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 | Not called from a user gesture, or the browser blocked it. Surface a “allow popups and retry” message. |
| Popup completes, no message arrives | event.origin check too strict/loose, or the popup was opened from a different origin than the listener. |
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 — check the interceptor’s urls matches your server origin exactly. |
| 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. |