Cross-Domain Setup
Every FanID customer is provisioned on a unique tenant domain — either a subdomain (https://your-org.fanid.co) or a
custom domain (https://fans.your-site.com). The widget talks to this tenant directly; all login pages, API
endpoints, and stored sessions live there. If you are embedding the widget on your own site, it authenticates with
the bearer transport — always. The cookie transport is not a cross-domain option: it applies only when the page
is the tenant portal, where the request is first-party and the portal's own session cookie is in scope.
Transport detection
When the widget initializes, it compares the embedding page's host with the host of baseUrl. The comparison is on
the full host, including the port.
- If the two hosts are exactly equal (e.g. the page is on
auburn.fanid.coandbaseUrlishttps://auburn.fanid.co), the widget uses the cookie transport. This is the tenant portal running the widget against itself, not a cross-domain embed. - In every other case — that is, every embed on a host you own — the widget uses the bearer transport.
There is no registrable-domain (eTLD+1) matching. A sibling subdomain such as shop.auburn.fanid.co embedding
against https://auburn.fanid.co is treated exactly like an unrelated third-party site and gets the bearer
transport, because browsers no longer make cross-subdomain third-party cookies dependable. The same holds when the
tenant and your site share a parent domain — fans.your-site.com and www.your-site.com are different hosts, so
that embed is bearer too.
The practical consequence: unless the page is served from the tenant host itself, plan for the bearer flow — which means the embedding origin must be one of your tenant's approved domains.
You can still override the decision with transport: 'cookie' | 'bearer' in the init() config, but the detection
is what you want outside of local testing.
Cookie transport — first-party only
When the widget is served from the tenant host itself, every API call goes out with credentials: 'include'. The
request is first-party, so the portal's own session cookie (set when the user signed in on the FanID portal) is sent
automatically and the backend reads it directly. There is no token to manage and nothing is stored client-side by
the widget itself.
This is the path used by the FanID portal, and it is the only situation in which the widget uses cookies. It is a permanently supported configuration — but it is not something you can opt into from your own domain, because the cookie simply is not in scope there. If you are integrating on a host you own, this section does not apply to you.
Bearer transport
When the widget runs on any host other than the tenant's — for example a club's site at auburntigers.com
embedding the widget against https://auburn.fanid.co — modern browsers refuse to send tenant cookies as
third-party. To make the session usable in that context, the widget uses a bearer flow.
Sign-in flow
- The user clicks "Sign in with FanID" on the embedding site. The widget redirects the browser to
${baseUrl}/login?transport=bearer&returnUrl=<current-page>. - The user authenticates on the tenant portal (or is already cookie-authenticated there).
- The portal mints a one-time exchange token bound to the embedding origin, then redirects the browser back to
<returnUrl>#fanid_exchange_token=<64-hex>. - On the embedding page, the widget detects the token in the URL hash and immediately scrubs it from the address
bar (via
history.replaceState, falling back to clearinglocation.hashif needed). The scrub happens before the network call so the token is never visible long enough to be copied or leaked through analytics tooling. - The widget POSTs the token to
/api/subscriber/auth/exchangeand receives the actual bearer session token plus itsexpiresAt. - Subsequent API calls go out with
credentials: 'omit'andAuthorization: Bearer <sessionToken>.
The exchange token is single-use, expires within 60 seconds of issue, and is consumed atomically server-side. The widget always scrubs it from the URL whether or not redemption succeeds, so a refresh after a failed exchange does not re-attempt with the same token.
Origin binding
The bearer session is bound by the backend to the embedding origin (derived from the return_url it received). A
session minted for auburntigers.com will return 401 if it is presented from any other origin. There is no shared
"FanID session" usable across multiple embedding sites.
Session storage
Bearer sessions are persisted client-side using a fallback chain so the user stays signed in across reloads even when one storage layer is unavailable:
localStorageunder the keyfanid:session:<api-host>, value{ sessionToken, expiresAt }JSON. Used whenever it is writable.- First-party cookie on the embedding origin, named
fanid_session_<api_host_with_underscores>, with attributesSecure(when the page is HTTPS),SameSite=Strict,Path=/, expiring at the session'sexpiresAt. Used as a fallback when localStorage throws (Safari private mode, quota errors, disabled storage). Persistence relies on thejs-cookiepackage the widget bundles. - In-memory
Mapof last resort — survives navigation within the same page, lost on reload.
When a write succeeds at one layer, lower layers are cleared so the session has a single canonical home. All of this is transparent to integrators; there is no API to call to "save" a session.
Approved domains
The bearer flow only works if the embedding origin is registered as one of your tenant's approved domains. You manage these in the FanID admin dashboard, under Domains on your organization. Specifically:
- The portal will only mint an exchange token for a
return_urlwhose origin is an approved domain. - The tenant's CORS layer rejects bearer API calls from origins that are not approved, so even an issued bearer cannot call the API from an unexpected site.
- Subdomains of an approved domain are accepted automatically — adding
your-site.comcoverswww.your-site.com,shop.your-site.com, etc.
Add every embedding origin (production and staging) as an approved domain before going live. If the widget cannot
resolve the organization at init time — typically because CORS rejected the public /api/subscriber/organization
call — it forces open the floating panel with a red "Domain not configured" error so misconfigured embeds are
obvious during integration.
Cross-tab sync
If a user signs out (or the bearer is cleared) in one tab on your site, every other tab on the same origin picks
that up via the storage event, refreshes its auth state, and re-renders to the logged-out view. The widget also
re-checks the bearer if the storage entry changes underneath it, which keeps multiple tabs in agreement without
each tab having to poll the API.
Logout
FanIDWidget.logout() posts to /api/subscriber/logout in either transport. In bearer mode the widget always
clears the stored session client-side, even if the network call fails — so the user is logged out locally and the
stale token cannot be replayed.
Request telemetry
Every API request the widget sends to your tenant carries two extra query parameters:
| Parameter | Value | Example |
|---|---|---|
widget_version | The version of the widget package | 0.2.2 |
widget_transport | The transport used for that request | bearer |
GET https://your-org.fanid.co/api/subscriber/me?widget_version=0.2.2&widget_transport=bearer
They are appended to all four endpoints the widget calls — /api/subscriber/me,
/api/subscriber/organization, /api/subscriber/auth/exchange and /api/subscriber/logout — and
they are added to any query string already on the request rather than replacing it.
The parameters are informational only: the widget never reads them back, and no behavior depends on
them. FanID uses them in its request logs to see which widget builds are live across embeds and
which transport each one selected — that is how support can tell whether a reported problem comes
from an old bundle, and whether an embed resolved to the transport you expected. Both transports are
permanently supported; neither is being phased out. For an embed on your own site the value should
always read bearer; a cookie reading there means baseUrl points at your own host by mistake.
There is no option to disable them, and no personal data is involved: the values are a package version string and one of two fixed transport names.
If you front your tenant with a reverse proxy, CDN, or WAF, make sure it forwards the query string
on /api/subscriber/* untouched. Stripping unknown query parameters is a common default and would
blank out this signal.
Custom domains
Provisioning a custom domain (e.g. fans.your-site.com as a CNAME to FanID) puts the tenant on a hostname you own,
which is useful for branding and for keeping the login page on your own domain. It does not by itself switch the
widget to the cookie transport: a page on www.your-site.com embedding against https://fans.your-site.com still
sees two different hosts and uses the bearer flow. Only a page served from fans.your-site.com itself gets the
cookie transport. Ask your FanID contact about custom-domain provisioning.
Testing locally
For local development against a remote tenant:
- Add your local origin (e.g.
https://localhost:3000) to the tenant's approved domains. - Use HTTPS locally — the bearer cookie fallback is
Secureon HTTPS pages and the FanID portal redirects between HTTPS origins. - Open DevTools and confirm
Authorization: Bearer …headers appear on/api/subscriber/mecalls.
Two localhost origins on different ports are different hosts, so detection already picks the bearer transport
during local development — the same path your production embed will take. Pass transport: 'cookie' to init()
only when you deliberately want to exercise the same-host path.
Staging vs. production
Pass the appropriate tenant URL at init time — no environment switch is needed inside the widget itself:
FanIDWidget.init({
baseUrl: isProduction
? 'https://your-org.fanid.co'
: 'https://staging.your-org.fanid.co',
})
Each environment keeps its own approved domains, so add every embedding origin you use against them to both.