diff --git a/.changeset/web-binding-cookie-only.md b/.changeset/web-binding-cookie-only.md
new file mode 100644
index 0000000..31db541
--- /dev/null
+++ b/.changeset/web-binding-cookie-only.md
@@ -0,0 +1,5 @@
+---
+'@seamless-auth/react': minor
+---
+
+`AuthProvider` is cookie transport only. Its `transport` prop no longer accepts `mode` or `tokenStorage`, and passing `mode: 'bearer'` throws, so in a browser the tokens stay with the server adapter in `httpOnly` cookies and a script on the page can never read them. `basePath`, `fetch` and `trustedOrigins` are unchanged. Native apps that need bearer transport use `@seamless-auth/react-native`, which is unaffected. No first-party template, the admin console or RoxTarget used bearer mode from `@seamless-auth/react`.
diff --git a/packages/react/README.md b/packages/react/README.md
index c0afaf9..b1e896c 100644
--- a/packages/react/README.md
+++ b/packages/react/README.md
@@ -318,25 +318,24 @@ Custom UIs get the same default through `useAuthClient()`, and can still overrid
Everything that differs between a browser and another platform sits behind a port on
`AuthProvider`. A web application configures nothing; the browser implementations are the
-defaults. A binding for another platform (React Native is the first) supplies its own:
+defaults. A port can be swapped, for example to drive the passkey ceremonies yourself:
```tsx
```
-- `transport` selects how the session travels. Cookie transport, the default, is the browser
- contract: the server adapter holds the tokens in `httpOnly` cookies and the client never sees
- them. Bearer transport is the native contract: the client holds the auth API's own tokens,
- presents the one a route needs in `Authorization`, stores the pair a sign-in returns through
- `tokenStorage`, and refreshes once through `POST /auth/refresh` when a request answers 401. At
- most one refresh is in flight at a time, because the auth API treats a replayed refresh token as
- theft and revokes the chain.
+- `transport` sets where the adapter is mounted (`basePath`, default `/auth`), the `fetch` to use,
+ and the extra origins `authorizedFetch` may reach (`trustedOrigins`). It is always cookie
+ transport: the server adapter holds the tokens in `httpOnly` cookies and the browser never sees
+ them, so a script on the page can ride the session but not read it. Passing `mode: 'bearer'`
+ throws. Native apps that need bearer transport, where the client holds the tokens in a keystore,
+ use `@seamless-auth/react-native`.
- `ports.passkeys` runs the WebAuthn ceremonies (`create`, `get`, and the two support checks).
`usePasskeySupport()` and every passkey flow go through it.
- `ports.oauthRedirect` opens the provider. The browser navigates away and the callback route
diff --git a/packages/react/src/AuthProvider.tsx b/packages/react/src/AuthProvider.tsx
index eb2c19e..19947cb 100644
--- a/packages/react/src/AuthProvider.tsx
+++ b/packages/react/src/AuthProvider.tsx
@@ -130,10 +130,13 @@ interface AuthProviderProps {
*/
magicLinkRedirectUri?: string;
/**
- * How the session travels to the server adapter. Defaults to cookie
- * transport, which is what a browser application wants.
+ * Where the adapter is mounted, the fetch to use, and the extra origins
+ * `authorizedFetch` may reach. Always cookie transport: the server adapter
+ * holds the tokens and the browser only HttpOnly cookies, so a script on the
+ * page can ride the session but never read it. Bearer transport is for
+ * native apps, through `@seamless-auth/react-native`.
*/
- transport?: Omit;
+ transport?: Omit;
/** Platform ports. Each one left out falls back to the browser's. */
ports?: Partial;
/**
@@ -166,22 +169,24 @@ export const AuthProvider: React.FC = ({
[providedPorts?.passkeys, providedPorts?.oauthRedirect]
);
- const {
- mode,
- basePath,
- tokenStorage,
- fetch: fetchImpl,
- trustedOrigins,
- } = transport ?? {};
+ // The type no longer offers bearer, but a JavaScript caller can still pass it,
+ // and quietly switching it to cookies would hide that their tokens were never
+ // where they thought.
+ if ((transport as { mode?: unknown } | undefined)?.mode === 'bearer') {
+ throw new TypeError(
+ '@seamless-auth/react is cookie transport only, so the browser never holds the tokens. Use @seamless-auth/react-native for bearer transport in a native app.'
+ );
+ }
+
+ const { basePath, fetch: fetchImpl, trustedOrigins } = transport ?? {};
// An inline array is a new value on every render, so it is compared by content.
const trustedOriginsKey = trustedOrigins ? JSON.stringify(trustedOrigins) : undefined;
const stableTransport = useMemo(
() =>
transport
? {
- mode,
+ mode: 'cookie' as const,
basePath,
- tokenStorage,
fetch: fetchImpl,
trustedOrigins: trustedOriginsKey
? (JSON.parse(trustedOriginsKey) as string[])
@@ -189,7 +194,7 @@ export const AuthProvider: React.FC = ({
}
: undefined,
// eslint-disable-next-line react-hooks/exhaustive-deps -- `transport` itself is deliberately not a dependency
- [mode, basePath, tokenStorage, fetchImpl, trustedOriginsKey]
+ [basePath, fetchImpl, trustedOriginsKey]
);
// Seeds the store like a `useState` initialiser. A server component hands over
diff --git a/packages/react/tests/authProvider.test.tsx b/packages/react/tests/authProvider.test.tsx
index 2c2c75f..2a60213 100644
--- a/packages/react/tests/authProvider.test.tsx
+++ b/packages/react/tests/authProvider.test.tsx
@@ -511,13 +511,8 @@ describe('AuthProvider', () => {
expect(typeof (seen[0] as { login: unknown }).login).toBe('function');
});
- it('passes transport and passkey ports through to the client', async () => {
+ it('passes transport options and passkey ports through to the client, as cookie transport', async () => {
mockFetchWithAuthImpl.mockResolvedValue(signedIn());
- const tokenStorage = {
- get: jest.fn(async () => null),
- set: jest.fn(async () => undefined),
- remove: jest.fn(async () => undefined),
- };
const passkeys = {
isSupported: () => true,
isPlatformAuthenticatorAvailable: async () => true,
@@ -535,7 +530,7 @@ describe('AuthProvider', () => {
render(
@@ -544,18 +539,39 @@ describe('AuthProvider', () => {
});
expect(createFetchTransport).toHaveBeenCalledWith(
- expect.objectContaining({ authHost: apiHost, mode: 'bearer', tokenStorage })
+ expect.objectContaining({
+ authHost: apiHost,
+ mode: 'cookie',
+ basePath: '/identity',
+ })
+ );
+ expect(createFetchTransport).not.toHaveBeenCalledWith(
+ expect.objectContaining({ tokenStorage: expect.anything() })
);
expect((ports as { passkeys: unknown }).passkeys).toBe(passkeys);
});
+ // The browser binding keeps tokens out of page script. A JavaScript caller
+ // can still pass bearer, and quietly using cookies instead would hide that.
+ it('refuses bearer transport', () => {
+ const spy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
+
+ expect(() =>
+ render(
+
+
+
+ )
+ ).toThrow(/cookie transport only/);
+
+ spy.mockRestore();
+ });
+
it('keeps one session across re-renders that pass fresh but equal prop objects', async () => {
mockFetchWithAuthImpl.mockResolvedValue(signedIn());
- const tokenStorage = {
- get: jest.fn(async () => null),
- set: jest.fn(async () => undefined),
- remove: jest.fn(async () => undefined),
- };
const passkeys = {
isSupported: () => true,
isPlatformAuthenticatorAvailable: async () => true,
@@ -567,8 +583,6 @@ describe('AuthProvider', () => {