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.

Everything below is plain HTTP: a popup, one postMessage, and a bearer token. There is no SDK to install and no framework requirement.


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.


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.

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 and postMessages it to window.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 from useEffect, timers, or promise callbacks are blocked by most browsers.
{
"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.


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;
}

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, …). 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 / .error

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.
  • {SERVER}/api/v0/rest/services/{baseId}/{tableName}/VectorTileServer/… — vector tiles.

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.


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.


If popups are unacceptable in your environment (kiosk mode, strict enterprise policy, embedded webview), use a full-page redirect:

  1. Send the user to {SERVER}/api/v0/auth/signin?callbackUrl={your app URL}.
  2. 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-token with credentials: '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.


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.


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.

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}.geojson

Both 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.

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.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 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.

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.


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 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.