There are two identities in almost every request, and keeping them distinct is the whole model:
| Identity | Who it is | Header |
|---|---|---|
| The application | Your server, acting as itself | Authorization: Bearer <jwt> or apiKey |
| The visitor | The customer using your site | x-client-authorization |
Your server always authenticates as the application. When a visitor is signed in, their token rides alongside as x-client-authorization — so AppEngine knows which application is asking and on whose behalf.
The visitor's token never becomes the Authorization header on a server-to-server call, and the application's token never reaches the browser. They are separate credentials with separate lifetimes.
See AppEngine → Conventions for the full header table.
The tenant
Every request needs orgid. It is the tenant, and without it AppEngine rejects the call:
Organization ID is required. Pass orgid as a header, query param, or body field.For multi-domain hosting — one deployment serving many customer domains — the tenant is resolved from the domain instead, using domainAsOrg: true, shared-org-id and x-client-host.
Application tokens
A server-side client fetches an application token once and reuses it:
async getHeaderWithToken() {
if (!this.token) {
this.token = await this.getAppToken();
}
const init = this.getBaseHeader();
init.headers['Authorization'] = `Bearer ${this.token}`;
return deepCopy(init);
}Because the token is cached for the life of the process, expiry has to be handled on the way back out. On a 401 the client clears the token, re-authenticates and replays the request once:
catch (err) {
if (this.renewTries < 2 && err?.response?.status === 401) {
this.token = null;
await this.getHeaderWithToken();
return await this.processRequestServer(method, path, data, /* … */);
}
throw err;
}A cached token is a per-process liability. If a token goes bad in a way that is not a 401 — a 403, a network fault during startup, a malformed response — the retry never triggers and that process stays unable to reach AppEngine for its entire lifetime, while still answering requests. Make the failure loud: return 503 from your own health check rather than serving a degraded page with a 200.
Client telemetry
x-client-info carries a JSON blob describing the visitor's request — host, protocol, referrer, user agent, language, timezone, screen size, device id. When it is present alongside an orgid, AppEngine writes a web_visit record fire-and-forget.
Build it on the server from the incoming request headers. It is plain header parsing — no network, no lookups:
const clientInfo = await getRequestInfo(request.headers);
clientInfo.timezone = request.headers.get('x-client-timezone');
// Prefer origin/referer for the real visitor-facing domain — the browser
// sets these, and behind a proxy `host` may be an internal service name.
const origin = request.headers.get('origin');
if (origin) clientInfo.host = new URL(origin).host;There is no origin to proxy through, so the app authenticates as an application and carries the person's token itself. The Flutter client handles both tokens for you — including the sign-in that comes back asking for a verification code.
Signing a visitor in
The visitor's session is established against your own origin, not AppEngine:
POST /api/user/customer/login → your server → AppEngineYour server stores the resulting token where the browser cannot read it as a bearer credential, and attaches it as x-client-authorization on subsequent calls. The browser only ever holds a session with you.
Never put a token in a URL. Query strings are logged by proxies, kept in browser history, and leak through Referer on every outbound link.