Skip to content
Torii docs

First-party cookies (proxy & CNAME)

When your React app at app.example.com calls the Torii API at *.torii.so, the session cookie that backs JWT refresh is a cross-site cookie. Torii sets it SameSite=None; Secure; Partitioned, so the CHIPS partitioned-cookie scheme keeps it working cross-site in Chrome, Firefox, and Safari 18.4+. Safari is still the weak spot:

Making the cookie first-party sidesteps all of this: the cookie stops being cross-site, so no browser’s third-party rules apply. There are two ways to do it, and you should pick one before going live:

  • Proxy origin: your app forwards /_torii/* paths to the Torii FAPI host on the server side. The cookie lands on your origin and is first-party. Available on every plan.
  • CNAME custom domain: you enter your application domain (example.com) and Torii derives the FAPI host torii.example.com, which you point at us with a DNS CNAME. Torii serves on it directly. After you verify the domain, regenerate your publishable key; it now encodes torii.example.com, so the SDK routes there with no proxyOrigin. The cookie’s Domain matches your app’s eTLD+1, so it’s first-party to your domain. Custom domains are free on every plan, production asks for your application domain when you create it.

Both work. Proxy is the default: ~5 min to wire up. CNAME has zero runtime cost on your infrastructure (no proxy hop, no SDK config) and is the recommended production setup.

Torii’s session cookie is HttpOnly; Secure; SameSite=None; Partitioned. SameSite=None is required or a cross-origin XHR wouldn’t include the cookie at all; Partitioned opts it into CHIPS so browsers store it in a jar keyed by your top-level site. How each browser treats it:

BrowserBehaviour
ChromeHonours Partitioned (CHIPS): the cookie persists per top-level site, so refresh keeps working cross-site.
FirefoxTotal Cookie Protection partitions cross-site cookies per top-level site; the partitioned cookie works.
Safari ≥ 18.4Supports Partitioned, so it works, unless ITP has classified the cookie’s domain as a tracker, in which case it’s blocked even when partitioned.
Safari < 18.4Blocks all third-party cookies outright (since Safari 13.1, 2020); no Partitioned support, so refresh fails.
BraveBlocks cross-site cookies by default; treat like old Safari.

That doesn’t break the first login (you get a JWT in the response body). The refresh is what breaks: once the cookie is blocked or gone, /auth/session/refresh returns 401 no_session and <ToriiProvider> flips the user to signed-out.

Forward /_torii/* paths from your own origin to the Torii FAPI host. Pass proxyOrigin to <ToriiProvider> so the SDK posts to your origin instead of the FAPI host directly.

// Vite dev server only; production needs a real reverse proxy (Caddy / nginx / your CDN).
import { defineConfig } from 'vite';
export default defineConfig({
server: {
proxy: {
'/_torii': {
target: 'https://your-app-name.torii.so',
changeOrigin: false, // KEEP the original Host header; Torii routes by it
secure: true,
},
},
},
});

The critical bit is forwarding the original FAPI host header to the upstream: Torii resolves the tenant from Host, so the upstream needs to see your-app-name.torii.so, not your origin.

src/main.tsx
<ToriiProvider
publishableKey={import.meta.env.VITE_TORII_PUBLISHABLE_KEY}
proxyOrigin="https://app.example.com"
>
<App />
</ToriiProvider>

proxyOrigin must be the absolute origin (scheme://host[:port], no path, no trailing slash) where your app runs. The SDK validates it synchronously at mount; invalid values throw with a clear message.

Open your app, sign in, and inspect the network tab. You should see requests to /_torii/auth/login, /_torii/auth/session/refresh, etc. under app.example.com, never your-app-name.torii.so. The session cookie’s Domain attribute should match your app’s domain.

Enter your application domain (example.com) in the dashboard’s Custom Domain panel; Torii derives the FAPI host torii.example.com. Add a DNS CNAME record pointing torii.example.com at your environment’s FAPI host (the target the panel shows). Once Torii verifies the domain and serves TLS for it, regenerate your publishable key; it now encodes torii.example.com, so the SDK routes there directly. No proxyOrigin, no reverse proxy. Torii resolves the tenant from the request Host, and the session cookie lands first-party to your domain.

In the Torii dashboard, navigate to your environment’s Settings and find the Custom Domain card. Enter your application domain (e.g. example.com). Torii derives the FAPI host torii.example.com and displays the CNAME target you should point it at.

DNS zone
torii.example.com. CNAME <cname-target-from-dashboard>. 3600

Once the record propagates, click Verify in the dashboard. Torii checks the CNAME resolves correctly; the first HTTPS request to torii.example.com then provisions a TLS certificate via ACME on demand.

After the domain is verified, regenerate your publishable key from the dashboard (Settings → Publishable keys). The new key encodes torii.example.com instead of the FAPI subdomain, so the SDK posts to your domain automatically. Drop it into publishableKey, and remove proxyOrigin if you had set it:

src/main.tsx
<ToriiProvider publishableKey={import.meta.env.VITE_TORII_PUBLISHABLE_KEY}>
<App />
</ToriiProvider>

That’s it: no proxyOrigin, no proxy rules. The publishable key is the only thing that changes. Your old FAPI-subdomain key keeps working until you revoke it, so cut over first, then revoke it from the same panel.

With a verified custom domain you can let the session work across every subdomain of your primary domain: sign in on app.example.com and stay signed in on dashboard.example.com. Enable Cross-subdomain session sharing in the Custom Domain card; Torii scopes the session cookie to your registrable domain (example.com) so it’s sent to every subdomain.

If you only want some subdomains to use the session, turn on Restrict allowed subdomains in the same card and list the exact hostnames (app.example.com, dashboard.example.com). By default, restriction off, any subdomain of your primary domain is allowed. Restriction requires a verified custom domain (it’s the primary domain the subdomains belong to).

ProxyCNAME
Setup time~5 min~30 min + DNS
Operational costOne extra hopNone
TLS managementYour CDN / hostTorii (ACME)
Works with serverless edgeYesYes
Works offline of your originNoYes
Best forSPAs, monolithsMarketing sites, multi-app accounts

A common shape: dev + staging use the proxy (everything’s already behind your reverse proxy), production uses CNAME (one less hop and your CDN doesn’t need to know about Torii).

Local dev usually doesn’t need either pattern: http://localhost:5173 is treated as same-site enough by every browser to keep the cookie working. Sandbox environments automatically allow localhost and 127.0.0.1 on any port, so credentialed cross-origin calls to your FAPI host work out of the box, there’s nothing to configure. (Web origins for production environments are derived from your verified custom domain instead.)

To exercise the proxy pattern locally, e.g. to mirror production before shipping, add a /_torii proxy rule to your dev server that points at your environment’s FAPI host (your-app.torii.so, from the dashboard) using the Vite snippet above, and set proxyOrigin="http://localhost:5173".

Non-browser stacks (Capacitor, Electron, extensions)

Section titled “Non-browser stacks (Capacitor, Electron, extensions)”

A handful of “browser-like” runtimes send an Origin that can’t be inferred from a domain: Capacitor (capacitor://localhost), Electron, or a browser extension (chrome-extension://<id>). For these, register the exact origins through the Backend API (secret key auth); there is no dashboard surface for them:

Terminal window
curl -X PUT https://api.torii.so/api/server/v1/allowed-origins \
-H "Authorization: Bearer $TORII_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"origins": ["capacitor://localhost", "chrome-extension://abc123"]}'

This list is additive to the origins derived from your domains; regular web origins never need an entry here.

Refresh works in Chrome/Firefox but Safari users get signed out

Section titled “Refresh works in Chrome/Firefox but Safari users get signed out”

You shipped without configuring either pattern. Chrome and Firefox keep the partitioned cookie, so it looks fine in testing, but older Safari blocks the cross-site cookie outright (and current Safari blocks it if ITP flags our domain), so refresh 401s for those users. Pick proxy or CNAME and wire it up.

Refused to set unsafe header "Host" in browser console

Section titled “Refused to set unsafe header "Host" in browser console”

You’re trying to override the Host header from the browser fetch call. Don’t: the Host header is fixed by the browser. Move the override to your server-side proxy (the snippets above).

Section titled “Cookie sets but doesn’t replay on the next request”

Check the cookie’s SameSite and Domain attributes in DevTools. If SameSite=None and Domain doesn’t match your app’s eTLD+1, the browser is treating it as third-party. Verify your proxy keeps the original Host header (so Torii’s Set-Cookie lands on your domain, not the FAPI host).

ToriiProvider prop reference

Every prop on <ToriiProvider> including proxyOrigin, with default-value behaviour. Open →