# 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 references from a kit that runs Better Auth on TanStack Start today. Tested July 2026.

*Published: 2026-07-19*
*Canonical: https://makerkit.dev/blog/tutorials/better-auth-tanstack-start*

---

**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 cite file and line numbers throughout, because our [TanStack Start SaaS kit](/tanstack-start) 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](/blog/tutorials/tanstack-start-vs-nextjs) for the mental model, then come back. Still deciding on a provider? [Better Auth vs Clerk vs NextAuth vs Supabase Auth](/blog/tutorials/better-auth-vs-clerk) 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 depend on each other. Here's what exists:

- **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 delegates 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):

```ts
/**
 * 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 that double-guard model in mind. 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 matter 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`:

```ts
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 the most time, **`tanstackStartCookies()` must be the last plugin in the array.** It's what sets auth cookies on outgoing responses, so anything registered after it can overwrite the cookie writes. Here's the shape of the instance (`packages/better-auth/src/auth.server.ts`):

```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 security decisions that are easy to miss:

- **`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):

```ts
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):

```ts
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):

```ts
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:

```ts
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 problem affects 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 suppress the redirect too. Rethrow it with `isRedirect()`:

```ts
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. Use `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 caller can invoke 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):

```ts
/** 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):

```ts
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 includes the authorization, and the handler's `context.user` is fully typed:

```ts
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 checks, 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 is stored 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):

```ts
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):

```ts
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`):

```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):

```ts
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):

```ts
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.

{% callout title="Match client and server plugins" type="warning" %}
Every server plugin with a client-facing surface needs its client counterpart in `auth-client.ts`. Enable `twoFactor()` on the server and you need `twoFactorClient()` on the client; the same holds for `emailOTP()` / `emailOTPClient()` and the organization plugin. Mismatch them and the calls exist on one side only, which fails in ways that are annoying to trace.
{% /callout %}

### 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):

```ts
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](/tanstack-start) 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 excerpt 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](/blog/tutorials/better-auth-vs-clerk) 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](/blog/tutorials/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](/blog/tutorials/tanstack-start-vs-nextjs) 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.

{% faq
   title="Frequently Asked Questions"
   items=[
     {"question": "How do I read the Better Auth session server-side in TanStack Start?", "answer": "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."},
     {"question": "Do I need a middleware.ts file for Better Auth on TanStack Start?", "answer": "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."},
     {"question": "Is a beforeLoad guard enough to secure my data?", "answer": "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."},
     {"question": "Why must tanstackStartCookies() be the last Better Auth plugin?", "answer": "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."},
     {"question": "How do I add multi-tenant organizations and roles with Better Auth in TanStack Start?", "answer": "Register Better Auth's organization plugin server-side. The active organization is stored 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."},
     {"question": "Does the MakerKit TanStack Start kit use Better Auth?", "answer": "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."},
     {"question": "Is TanStack Start production-ready for a SaaS in 2026?", "answer": "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](/blog/tutorials/tanstack-start-vs-nextjs). If you're still choosing a provider, read [Better Auth vs Clerk](/blog/tutorials/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](/tanstack-start).
