Better Auth + TanStack Start: Wiring Auth the Right Way (2026)

Wire Better Auth into TanStack Start the production way: a server-function session boundary, SSR cookies on Nitro without middleware.ts, beforeLoad guards, org/RBAC middleware, and 2FA/OTP as shipped. Real file:line receipts from a kit that runs Better Auth on TanStack Start today. Tested July 2026.

Production Better Auth on TanStack Start comes down to four decisions most quickstarts skip: configure the auth instance so tanstackStartCookies() is the last plugin (it writes session cookies on the response), read the session server-side with auth.api.getSession() wrapped in a server function, guard navigation with beforeLoad, and authorize every server function itself. Get the last one wrong and you ship an app that looks authenticated but leaks data.

Clerk's TanStack Start guide is a clean hosted quickstart that ends at "you're signed in." Better Auth's own docs are framework-agnostic, so they show you the library API but not the full TanStack Start SSR path. Nobody connects the pieces of TanStack Start authentication around a real, shipped setup: session on Nitro, protected routing through TanStack Router, and the server function as the actual security boundary. That gap is what this post fills.

I'll pull real file:line receipts throughout, because our TanStack Start SaaS kit runs on Better Auth today (Drizzle and Prisma variants). Unlike a generic integration article, every excerpt below is code that ships. This is written for TanStack Start on Vite + Nitro (it moved off Vinxi, and is currently a Release Candidate) with Better Auth current and React 19, tested July 2026. TanStack Start is still young and moving fast, so date anything you copy from tutorials, including this one.

New to the framework? Start with TanStack Start vs Next.js for the mental model, then come back. Still deciding on a provider? Better Auth vs Clerk vs NextAuth vs Supabase Auth covers that tradeoff; this post assumes you've picked Better Auth and want to wire it correctly.

Why the quickstarts aren't enough

The problem isn't that good material doesn't exist. It's that no single source connects auth to the TanStack Start production path, and the pieces interlock. Here's the landscape:

  • Clerk's TanStack Start guide: official and clean, but it's a hosted service. Your users live in Clerk's database, you pay per monthly active user, and there's no self-hosted session or org model to own. Fine for a prototype, a different set of tradeoffs for a real SaaS.
  • Better Auth's docs: canonical for the library API (plugins, sessions, organizations, 2FA), but framework-agnostic. They don't walk the TanStack Start SSR path (Nitro cookies, beforeLoad, the server-function boundary) end to end in one place.
  • Generic "auth on TanStack Start" posts: usually cover a single "protect a route" example and stop. They rarely mention that a route guard protects navigation, not data.
  • The Convex quickstart: ranks well, but it punts authentication to a third party, so it's no help if you want to own your users.

Merge the library API with the framework plumbing, make the security boundary explicit, and add the multi-tenant and 2FA pieces the kit actually ships, and you have the setup that survives real users. Let's build it.

The mental model: your server function is the security boundary

Start here, because getting this wrong is what makes "authenticated" apps leak data.

TanStack Start has no middleware.ts and no Next.js-style proxy sitting in front of every route. There's a global request middleware you register yourself, route-level beforeLoad guards, and server functions. The critical thing to internalize: a beforeLoad guard protects navigation, not data. It decides whether a user is allowed to see a screen. It does nothing to protect the endpoints that screen calls.

Server functions (createServerFn) are directly-callable POST RPCs. Anyone who can reach your app can invoke one directly, with or without ever loading the route that normally calls it. They run through the request pipeline like any other POST, which is precisely why each one has to authorize itself. Server functions do not "bypass" anything; they're just endpoints, and endpoints need auth.

So you guard twice, and the two guards do different jobs:

  • beforeLoad guard = UX. Redirect anonymous users to sign-in before they see a protected screen. Fast, good experience, not a security control.
  • Server function auth = security. Every server function that reads or writes private data checks the session itself. This is the real boundary.

Our kit encodes exactly this split. Here's the guard file, including the comment we wrote to ourselves (apps/web/src/lib/auth/guards.ts, lines 14-20):

/**
* Route guards for `beforeLoad`. Attach to a layout/route to gate access.
*
* These guards control navigation only. Every server function and loader that
* reads or writes private data must also authorize itself (via
* `@kit/action-middleware` or an in-handler session check) — a guard keeps a
* user out of a screen, it does not protect the endpoints the screen calls.
*/

Keep the double-guard picture in your head. Everything below is an instance of it.

Configuring the Better Auth instance

Better Auth is configured in one place, packages/better-auth/src/auth.server.ts, and the whole kit inherits it. Two details of that file are load-bearing for TanStack Start specifically, and both are easy to get wrong.

First, the imports. The kit pulls betterAuth from better-auth/minimal and the cookie writer from better-auth/tanstack-start:

import { drizzleAdapter } from '@better-auth/drizzle-adapter';
import { betterAuth } from 'better-auth/minimal';
import { tanstackStartCookies } from 'better-auth/tanstack-start';

Second, and this is the one that costs people an afternoon, tanstackStartCookies() must be the last plugin in the array. It's what sets auth cookies on outgoing responses, so anything registered after it can clobber the cookie writes. Here's the shape of the instance (packages/better-auth/src/auth.server.ts):

export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'pg' }),
advanced: {
useSecureCookies: IS_PRODUCTION,
},
trustedOrigins: authConfig.baseURL ? [authConfig.baseURL] : [],
secret: authConfig.secret,
baseURL: authConfig.baseURL,
plugins: [
adminPlugin,
organizationPlugin,
otpPlugin,
createTwoFactorPlugin(),
// ...conditional sign-in methods
// MUST be last: sets auth cookies on outgoing responses.
tanstackStartCookies(),
],
// ...
});
export type Session = typeof auth.$Infer.Session;

Three config lines are quiet security decisions:

  • baseURL must be a fully-qualified URL. If it's unset, Better Auth falls back to the request's Host header, which is attacker-controlled behind a permissive proxy. The kit fails to boot without it rather than trusting the header.
  • useSecureCookies: IS_PRODUCTION marks cookies Secure in production so they only travel over HTTPS.
  • trustedOrigins pins the origins Better Auth will accept, which backs its CSRF protection.

The secret is validated at boot with Zod: BETTER_AUTH_SECRET must be at least 32 characters, and in production it cannot be the development test secret. Generate one with openssl rand -base64 32. In development a default test secret is used automatically, so local setup just works; production refuses to start without a real one.

Reading the session server-side

Every authorization decision starts with the session, and on TanStack Start you read it from the request headers. The kit's getSession() does exactly that, then memoizes the result per request so repeated reads in one request only hit Better Auth once (packages/better-auth/src/context.ts, lines 46-50):

export function getSession() {
return requestMemo('session', () =>
auth.api.getSession({ headers: getRequestHeaders() }),
);
}

getRequestHeaders() comes from @tanstack/react-start/server. If you're following an older tutorial you'll see vinxi/h3 or getWebRequest(); those predate the move to the Vite plugin and Nitro. On current TanStack Start, @tanstack/react-start/server is the correct server surface. When something doesn't line up with a guide you're reading, check its date first.

There's a catch: raw request headers are available during a server-side render or inside a server function, but not during a client-side navigation. So you wrap the session read in a server function, which the router can call from anywhere. That's what fetchSession is (apps/web/src/lib/auth/session.functions.ts, lines 11-13):

export const fetchSession = createServerFn({ method: 'GET' }).handler(() =>
getSession(),
);

Now beforeLoad, loaders, and components can all await fetchSession() and get a trustworthy session whether the page rendered on the server or navigated on the client.

Protecting routes with beforeLoad

This is the UX layer. A beforeLoad guard runs before a route (and its children) loads, so it's the right place to bounce anonymous users to sign-in before they ever see a protected screen. The kit's requireAuth awaits fetchSession(), redirects when there's no session, and returns the session into route context (apps/web/src/lib/auth/guards.ts, lines 27-46):

export async function requireAuth({
location,
context,
}: {
location: { href: string };
context: { locale: string };
}) {
const session = await fetchSession();
if (!session) {
throw redirect({
href: localizeUrl(
`/auth/sign-in?redirect=${encodeURIComponent(location.href)}`,
context.locale,
),
});
}
return { session };
}

Attach it to an authenticated layout route so every child inherits the gate:

export const Route = createFileRoute('/_authenticated')({
beforeLoad: requireAuth,
});

The returned session lands in route context, so child routes and loaders read it without calling fetchSession again on the same navigation.

One gotcha bites people who wrap guard logic in try/catch: TanStack Router signals a redirect by throwing. If you catch errors around a server-function call and log-and-swallow them, you'll eat the redirect too. Rethrow it with isRedirect():

import { isRedirect } from '@tanstack/react-router';
try {
const session = await fetchSession();
// ...
} catch (error) {
if (isRedirect(error)) {
throw error; // let the router handle the redirect
}
// genuine failure: log, show an error boundary, etc.
}

If your guard is as simple as the one above you don't need the try/catch at all. Reach for isRedirect() only when you're wrapping calls that might both redirect and fail for real. To send signed-in users away from the sign-in and sign-up screens, add the inverse guard, which is exactly what the kit's redirectIfAuthenticated does: same pattern, condition flipped.

Protecting the server functions themselves

This is the boundary that actually matters, and it's the step the quickstarts don't have because they don't have server functions at all.

Every server function that touches private data authorizes itself. The beforeLoad guard did nothing for these endpoints; a determined caller hits them directly. So the rule is simple: no private read or write without a session check inside the handler.

Rather than paste a session check into fifty handlers, factor it into function middleware and pre-bind it. That's what our @kit/action-middleware package does. It exports authAction(), organizationAction(), and adminAction() factories that layer authorization onto createServerFn before you write .handler (packages/action-middleware/src/client.ts, lines 66-84):

/** Requires an authenticated user — `context.user`, `context.session`. */
export const authAction = (opts: ActionOptions = {}) =>
createServerFn({ method: 'POST', ...opts }).middleware([
errorMiddleware,
authMiddleware,
]);
/** Requires an active organization — `context.organizationId`, `context.role`. */
export const organizationAction = (opts: ActionOptions = {}) =>
createServerFn({ method: 'POST', ...opts }).middleware([
errorMiddleware,
organizationMiddleware,
]);

The authMiddleware they compose is the whole point. It resolves the session, throws Unauthorized when there's none, and otherwise passes a typed user and session down to the handler's context (packages/action-middleware/src/middleware.server.ts, lines 39-54):

export const authMiddleware = createMiddleware({ type: 'function' }).server(
async ({ next }) => {
const result = await getSession();
if (!result) {
throw new Error('Unauthorized');
}
return next({
context: {
user: result.user,
session: result.session,
},
});
},
);

Now defining a protected mutation carries the authorization for free, and the handler's context.user is fully typed:

export const updateProfile = authAction()
.validator(UpdateProfileSchema)
.handler(async ({ data, context }) => {
// context.user is guaranteed here, the middleware enforced it
});

One subtlety worth knowing: the kit defines these as factories and also exports pre-composed middleware tuples, because how you write the createServerFn call affects the bundle. A literal createServerFn(...) at the definition site lets the TanStack Start compiler detect the server function and strip its handler (and the database and auth graph it pulls in) from the client bundle. Guard navigation with beforeLoad, guard data with function middleware. Two locks, two jobs.

Multi-tenant org auth and RBAC, as shipped

Most SaaS apps aren't just "logged in or not." They're team workspaces with roles. Better Auth's organization plugin handles the multi-tenancy, and the kit wires role and permission checks on top of it.

The organization plugin is registered server-side, and the active organization rides in the session as activeOrganizationId. The organizationMiddleware reads it, resolves the caller's role in that org through Better Auth, and injects both into context (packages/action-middleware/src/middleware.server.ts, lines 74-88):

export const organizationMiddleware = createMiddleware({ type: 'function' })
.middleware([authMiddleware])
.server(async ({ next }) => {
const organizationId = await getActiveOrganizationId();
if (!organizationId) {
throw new Error('No active organization');
}
const { role } = await auth.api.getActiveMemberRole({
headers: getRequestHeaders(),
});
return next({ context: { organizationId, role } });
});

Note it composes authMiddleware first, so an org action is authenticated and org-scoped in one step. For finer control, the kit ships two more middleware. withMinRole('owner') enforces a minimum role level, and withFeaturePermission() checks a specific RBAC permission through Better Auth's access-control API (packages/action-middleware/src/middleware.server.ts, lines 148-159):

const { success, error } = await auth.api.hasPermission({
headers: getRequestHeaders(),
body: { organizationId, permissions },
});
if (error) {
throw new Error('Permission check failed in authorization middleware');
}
if (!success) {
throw new Error('Unauthorized');
}

Layer these onto an action the same way you'd add any middleware, so a destructive org mutation can require the owner role while a read requires only membership. This is the part Clerk's guide and the framework-agnostic docs don't show you: a real multi-tenant authorization model wired into the server-function boundary, not just a signed-in check.

2FA and email OTP, without hand-rolling

The kit ships two-factor auth and email OTP as Better Auth plugins, so you enable them rather than build them.

Two-factor is a one-liner on the server (packages/better-auth/src/plugins/two-factor.ts):

export function createTwoFactorPlugin() {
return twoFactor({
issuer: process.env.VITE_PRODUCT_NAME,
});
}

The client half handles the redirect into the verification step. When a sign-in needs a second factor, twoFactorClient sends the user to the verify page (packages/better-auth/src/auth-client.ts, lines 24-33):

twoFactorClient({
onTwoFactorRedirect() {
const redirect = new URLSearchParams(location.search).get('redirect');
window.location.href =
'/auth/verify' + (redirect ? `?redirect=${redirect}` : '');
},
}),

Email OTP is registered with one security choice worth calling out. The kit stores OTP codes with Better Auth's keyed encrypted storage, not the hashed option (packages/better-auth/src/plugins/otp-auth.ts, lines 10-16):

export const otpPlugin = emailOTP({
// Use BA's keyed `'encrypted'` storage (XChaCha20-Poly1305 keyed by
// BETTER_AUTH_SECRET) — NOT `'hashed'`, which is unkeyed SHA-256 over the
// 6-digit codespace and reversible by rainbow table on any DB read.
storeOTP: 'encrypted',
// ...sendVerificationOTP
});

A 6-digit code is a tiny keyspace. An unkeyed hash of it is reversible with a precomputed table the moment someone reads your database. Keyed encryption ties recovery to the secret, so a DB leak alone doesn't expose live codes. It's a small flag with a real difference, and the sort of thing that's easy to miss when you wire OTP yourself.

No middleware.ts? Cookies and CSRF live in start.ts

In Next.js, middleware.ts is where global request concerns go. TanStack Start doesn't have that file, so the job moves to the global request middleware registered in start.ts. Session cookies themselves are handled by tanstackStartCookies() inside the auth instance (that's why it has to be last), but cross-cutting request policy sits here.

The kit scopes CSRF protection to server functions and sets security headers on every response (apps/web/src/start.ts, lines 43-45 and 92-94):

const csrfMiddleware = createCsrfMiddleware({
filter: (ctx) => ctx.handlerType === 'serverFn',
});
export const startInstance = createStart(() => ({
requestMiddleware: [csrfMiddleware, securityHeadersMiddleware],
}));

CSRF is scoped to serverFn on purpose: the Better Auth catch-all route (/api/auth/*) is hit by external origins like OAuth callbacks and billing webhooks, and Better Auth enforces its own trusted-origin and signature checks there. The securityHeadersMiddleware sets X-Frame-Options: DENY and X-Content-Type-Options: nosniff on responses. The mental model to hold: tanstackStartCookies() keeps the session cookie fresh, beforeLoad is your navigation gate, and function middleware is your data gate. Three layers, no middleware.ts.

Skip the wiring: the MakerKit TanStack Start kit

You can hand-roll everything above, and now you know exactly what "everything" is. If you'd rather start from a production baseline, that's what we sell, so here's the honest version.

Our TanStack Start SaaS kit ships this exact architecture today: the Better Auth instance with the cookie plugin ordered correctly, the beforeLoad guards, the start.ts request-middleware layer, and the authAction() / organizationAction() / adminAction() factories that make "every server function authorizes itself" the default instead of a discipline you maintain by hand. It also ships the multi-tenant org model, RBAC, 2FA, email OTP, magic links, passkeys, and social login on the same Better Auth foundation. Every receipt in this post is pulled straight from that codebase, and it comes in Drizzle and Prisma variants that swap only packages/database (a Supabase variant is also available, though it runs Supabase Auth rather than Better Auth).

If your decision is really about which auth provider to standardize on rather than how to wire this one, Better Auth vs Clerk vs NextAuth vs Supabase Auth covers that tradeoff and what the kit ships. And if you'd rather run Supabase Auth on TanStack Start, we wrote that setup up separately in TanStack Start + Supabase Auth.

Quick Recommendation

This production setup is best for:

  • Teams building a real SaaS on TanStack Start who want to own their users in their own Postgres, with auth that survives direct endpoint calls.
  • Anyone who needs multi-tenant orgs, roles, and permissions rather than a plain signed-in check.

Skip the full setup if:

  • You're building a throwaway prototype where a hosted quickstart is fine and per-user pricing never matters.
  • You haven't picked the framework yet; read the hub comparison first.

Our pick: Configure the Better Auth instance with tanstackStartCookies() last, read the session through a createServerFn wrapper, guard navigation with beforeLoad, and authorize every server function with function middleware. Guard navigation once, guard data twice.

Frequently Asked Questions

How do I read the Better Auth session server-side in TanStack Start?
Call auth.api.getSession({ headers: getRequestHeaders() }), importing getRequestHeaders from @tanstack/react-start/server. Wrap that in a createServerFn so beforeLoad, loaders, and components can read the session during client-side navigation too, where raw request headers aren't available. The kit memoizes the read per request so repeated calls hit Better Auth once.
Do I need a middleware.ts file for Better Auth on TanStack Start?
No. TanStack Start has no middleware.ts. Session cookies are written by the tanstackStartCookies() plugin inside your Better Auth instance, which must be the last plugin in the array. Cross-cutting request policy like CSRF and security headers goes in the request middleware you register in start.ts via createStart.
Is a beforeLoad guard enough to secure my data?
No. A beforeLoad guard protects navigation, not data. Server functions are directly-callable POST endpoints that a client can invoke without ever loading the route, so each one that touches private data must authorize itself. Guard navigation with beforeLoad, and guard data inside the server function with function middleware or an in-handler session check.
Why must tanstackStartCookies() be the last Better Auth plugin?
tanstackStartCookies() (from better-auth/tanstack-start) writes the auth session cookies onto outgoing responses. If another plugin runs after it, that plugin can interfere with the cookie writes, so the kit places it last in the plugins array. This is the number-one reason a Better Auth session works locally but doesn't persist.
How do I add multi-tenant organizations and roles with Better Auth in TanStack Start?
Register Better Auth's organization plugin server-side. The active organization rides in the session as activeOrganizationId. The kit's organizationMiddleware reads it and resolves the caller's role via auth.api.getActiveMemberRole, while withMinRole and withFeaturePermission (backed by auth.api.hasPermission) enforce role levels and RBAC permissions inside server functions.
Does the MakerKit TanStack Start kit use Better Auth?
Yes. The shipped TanStack Start SaaS kit runs on Better Auth today, in Drizzle and Prisma variants, with organizations, RBAC, 2FA, email OTP, magic links, passkeys, and social login already wired. A Supabase variant is also available, though it runs Supabase Auth rather than Better Auth.
Is TanStack Start production-ready for a SaaS in 2026?
It's viable for production SaaS and we ship a kit on it, but it's the younger framework and still moving fast (Vite + Nitro, having moved off Vinxi). Pin your versions, date any tutorial you follow, and use @tanstack/react-start/server as the current server surface. For the full framework tradeoff, see our TanStack Start vs Next.js comparison.

Next steps

You now have the whole production path: a correctly-ordered Better Auth instance, a server-function session read, double-guarded routes and data, multi-tenant org auth, and 2FA plus OTP as shipped. If you're weighing the framework itself, read TanStack Start vs Next.js. If you're still choosing a provider, read Better Auth vs Clerk. And if you'd rather start from a kit that already ships this architecture on Better Auth, take a look at the TanStack Start SaaS kit.

Some other posts you might like...