Skip to Content
Auth for your appSPA (no backend)

SPA (no backend)

Add sign-in to a pure single-page app — Vite, Create React App, a static site — with no server of your own and no tokens in JavaScript. Foir sets an HttpOnly session cookie on your own domain; your SPA silently exchanges it for a short-lived access token that never leaves memory.

This gives a backend-less SPA the same security posture as the BFF pattern, without the backend.

Note: This requires a custom login domain on the same registrable domain as your app — for example an app on app.acme.com and login on login.acme.com. That is what makes the session cookie first-party to your app. If you can’t add a DNS record, use the Quickstart token flow instead and hold the access token in memory.

How it works

Browser (app.acme.com) Foir (login.acme.com) Foir (api.foir.dev) │ │ │ │ no session → redirect to login │ │ │───────────────────────────────────────►│ │ │ …user authenticates (pwd/OTP/OAuth)… │ │ │ ◄── 302 back to app │ │ │ Set-Cookie: foir_customer_session │ │ │ HttpOnly; Secure; SameSite=Lax │ │ │ Domain=.acme.com │ │ │ │ │ │ GET /session/token (cookie sent) │ │ │───────────────────────────────────────►│ │ │ ◄── { access_token, expires_in: 900 } │ │ │ │ │ │ POST /graphql Authorization: Bearer <access_token> │ │─────────────────────────────────────────────────────────────────────►│

Two credentials, deliberately unequal:

  • The session cookie is long-lived (30 days) and HttpOnly, so JavaScript cannot read it and cross-site scripting cannot steal it. It is a session credential only — it never authenticates a data call.
  • The access token lives 15 minutes and is held in memory by the SDK. It is never written to localStorage, sessionStorage, or a cookie. If it leaks, it expires.

There is no refresh token anywhere in this flow. Re-exchanging the cookie is the refresh.

1. Register a relying party

In your Foir config, give the relying party your app’s redirect URI, a custom login domain, and the origins your SPA runs on:

defineRelyingParty({ clientId: 'acme-spa', name: 'Acme', redirectUris: ['https://app.acme.com/callback'], customDomains: ['login.acme.com'], postLoginUrl: 'https://app.acme.com', allowedOrigins: ['https://app.acme.com'], });

Run foir push. This registers the domain and prints a DNS challenge — publish the TXT record, then verify the domain. Until the domain is verified, no session cookie is issued.

allowedOrigins is what lets the browser exchange the cookie for a token. It is an exact allowlist, matched like redirectUris: scheme and host (and port, if it isn’t 443), https only, no wildcards, no paths. List every origin your app is served from — including any preview or staging origin that needs to sign in.

Note: There is no domain-wide default. Granting every origin on acme.com would also grant a subdomain you no longer control, or one serving user-generated content — either could then mint your customers’ access tokens. So if you list nothing here, the browser session doesn’t work, and getToken() returns null. If that’s what you’re seeing, this is the first thing to check.

You can also set all of this on the relying party in the admin UI, under Access → Sign-in → Relying Parties.

See Config reference for the full defineRelyingParty surface.

2. Wrap your app

import { FoirProvider } from '@foir/sdk/react'; export function Root() { return ( <FoirProvider env={{ PLATFORM_API_URL: 'https://api.foir.dev/graphql', PLATFORM_API_KEY: 'pk_live_…', }} hostedSession={{ loginOrigin: 'https://login.acme.com' }} > <App /> </FoirProvider> ); }

hostedSession replaces the default token store. With it set, the SDK holds nothing in localStorage.

3. Sign in and out

Sign-in is a redirect, not a form — Foir renders the login UI on your domain:

import { useFoirSession, useFoirAuth } from '@foir/sdk/react'; function Header() { const { isAuthenticated, isLoading } = useFoirSession(); const { hostedSignInUrl, signOut } = useFoirAuth(); if (isLoading) return null; return isAuthenticated ? ( <button onClick={() => signOut()}>Sign out</button> ) : ( <a href={hostedSignInUrl()}>Sign in</a> ); }

hostedSignInUrl() sends the user to your login domain and returns them to where they were. signOut() revokes the session server-side and clears the cookie.

The password and OTP actions (signIn, register, signInWithOtp) do not apply in this mode and will throw — the customer’s credential never passes through your app, which is the point.

4. Call the API

Ask for a token before each request. The SDK returns a cached one, or silently re-exchanges the cookie if it is close to expiry:

import { useFoirAuth } from '@foir/sdk/react'; function useOrders() { const { getToken } = useFoirAuth(); return async function fetchOrders() { const token = await getToken(); // null when signed out const res = await fetch('https://api.foir.dev/graphql', { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': 'pk_live_…', Authorization: `Bearer ${token}`, }, body: JSON.stringify({ query: '{ currentUser { id email } }' }), }); return res.json(); }; }

You never handle expiry yourself: the SDK re-exchanges before the token dies, collapses concurrent callers into a single round-trip, and signs the user out only if the cookie itself is gone (a network blip does not log anyone out).

Without React

The same client is framework-agnostic:

import { createFoirSessionClient } from '@foir/sdk/auth'; const session = createFoirSessionClient({ loginOrigin: 'https://login.acme.com', onSignOut: () => location.assign(session.signInUrl()), }); const token = await session.getToken(); // null when signed out const user = await session.getUser(); // { customerId, email } | null await session.signOut();

Why the login domain is required

The cookie has to be first-party to your app, which means the app and the login screen must share a registrable domain (acme.com). Browsers will not send a cross-site cookie on a background request, so an SPA on myapp.vercel.app cannot hold a Foir session on api.foir.dev — the cookie would be third-party, and the session endpoint refuses credentialed cross-site calls by design.

This is the same constraint every provider in this category imposes: production cookie sessions require a DNS record pointing at the provider. If you can’t add one, use the token flow and keep the access token in memory.

What’s next

Last updated on