Hybrid setup
Sign in with your backend from the browser and call your API there, while the server keeps the session in its cookie.
In a hybrid setup, the browser talks to your backend: it signs in, and calls your API with the access token. The server still keeps the session in its cookie, refreshes it, and renders who is signed in. Use it when your backend's sign-in has to run in the browser, such as a provider's SDK, or when your pages call your API themselves.
Two things change from a server-rendered setup:
- Signing in. The browser gets the tokens from your backend and sends
them to a server function, which seals them into the cookie with
session.signIn. - Calling your API. The session route gives the browser the access token,
and
createAuthFetchsends it. The refresh token stays in the cookie.
This page builds on the TanStack Start guide. With Next.js or React Router, do the same in a Server Function or an action.
Before you begin
Follow the TanStack Start guide through step 5.
This page replaces its src/auth/functions.ts, src/auth/client.ts and
src/sign-in.ts.
Then let the session route serve the access token, with clientCredentials
in createServerAuth, in src/auth/server.ts:
export const serverAuth = createServerAuth({
// …the options from the guide
clientCredentials: true,
});1. Sign in with your backend in the browser
The browser calls your backend's sign-in, which must allow requests from your site (CORS):
import { } from "zod";
const = "https://api.example.com";
const = .({
: .(),
: .(),
});
const = .({ : .(), : .() });
/** Signs in with your backend, from the browser. */
export async function (: string, : string) {
const = await (`${}/auth/login`, {
: "POST",
: { "Content-Type": "application/json" },
: .({ , }),
});
if (!.) throw new (`Sign-in failed (${.})`);
const : unknown = await .();
return { ....(), : .() };
}2. Send the tokens to the server
session.signIn seals what it's given, and never asks your backend. So the
server function decides what to trust. Check the tokens first, unless the
cookie's user is only ever shown.
Check the tokens first
The server function takes the tokens only, and asks your backend who they belong to. A forged or expired token is refused, and the cookie's user comes from your backend, not from the browser:
import { } from "@tanstack/react-start";
import { } from "zod";
import { } from "./server";
const = "https://api.example.com";
export const = ().(() =>
.(),
);
export const = ({ : "POST" })
.(.({ : .(), : .() }))
.(async ({ }) => {
// Proves the access token is real, and reads its user.
const = await (`${}/auth/me`, {
: { : `Bearer ${.}` },
});
if (!.) throw new ("Your backend refused the token");
const : unknown = await .();
const = .({ : .(), : .() }).();
return .({ ..., });
});The client's signIn takes the tokens, and sends them to signInFn:
import { } from "@monarcode/session-kit";
import { , } from "./functions";
export function () {
return ({
: "app",
: null, // filled by loadSession, then by hydration
: "/api/session",
: () => (),
: (: { : string; : string }) =>
({ : }),
});
}
export type = <typeof >;The form signs in with your backend, then with the client. The sign-in page's guard sends the user on, so the form doesn't navigate:
import type { } from "./auth/client";
import { } from "./backend";
/** Signs in, and resolves the message to show when it fails. */
export async function (
: ,
: string,
: string,
): <string | null> {
try {
const { , } = await (, );
await .({ , });
return null;
} catch () {
return instanceof ? . : "Could not sign in";
}
}With fromAccessToken as the server's
user option, the server reads the user from the access token instead. Give
it a decode that verifies the token's signature, and that's the check.
Pass the tokens without a check
The server function takes the tokens and the user the browser sends, and seals them as they are:
import { } from "@tanstack/react-start";
import { } from "zod";
import { } from "./server";
export const = ({ : "POST" })
.(
.({
: .(),
: .(),
: .({ : .(), : .() }),
}),
)
.(({ }) => .());The client's signIn takes what your backend's sign-in returned, so the
form passes it login's result as it is:
signIn: (result: Awaited<ReturnType<typeof login>>) =>
trustingSignInFn({ data: result }),Anyone can then put any user in their own cookie, with tokens of their own. That's safe only when the cookie's user is shown, never trusted:
- Your backend authorizes every request with the access token, and
nothing on your server acts on
session.useralone, such as reading another account's data by itsid. - A forged token gets nowhere. Your API refuses it, and the first refresh fails and signs the user out.
When in doubt, check the tokens first. It costs one request at sign-in.
3. Call your API from the browser
createAuthFetch from @monarcode/session-kit/http sends the access token
that the session route serves. Make one per client, since the client is made
once per page load:
import { , } from "react";
import { } from "@monarcode/session-kit/http";
import { } from "@monarcode/session-kit/tanstack-router";
export function () {
const = ();
const = (
() => (, { : "https://api.example.com/" }),
[],
);
const [, ] = <number | null>(null);
async function () {
const = await ("orders");
if (!.) throw new (`Orders failed (${.})`);
const : unknown = await .();
(.() ? . : 0);
}
return (
<>
< ={() => void ().(() => {})}>Load orders</>
{ !== null && <>{} orders</>}
</>
);
}With ky, axios or openapi-fetch, pass apiFetch as the client's fetch: see
Other HTTP clients.
The browser keeps the access token in memory, never the refresh token. After a 401, the client asks the session route for a new token, and the server refreshes with the cookie's refresh token. Server functions can still call your API too, as the guide's Call your API shows.
Things to know
- The refresh token passes through the browser once, in your backend's sign-in response, on its way to the server. After that, page scripts never see it. If your backend's sign-in can run on the server, sign in there, as the setup guides do.
- Your backend must allow your site (CORS) for its sign-in and the API calls you make from the browser. Session kit's own requests go to your session route, on your site.
- Signing out works as in the guide's
Sign out, through the session
route. To end the session at your backend too, give
createServerAutharevoke. - Security has more on what the cookie's user can be trusted for, in Your backend decides what's allowed.