Production Supabase Auth on TanStack Start needs four things most quickstarts skip: a cookie-correct @supabase/ssr server client wired to @tanstack/react-start/server, supabase.auth.getClaims() for a verified JWT on every request, beforeLoad guards plus a real RBAC middleware stack for server functions, and RLS-scoped Postgres RPCs so data access doesn't leak into hand-rolled account_id filters. We ship all of it today in the TanStack Start SaaS kit's Supabase variant, and every code sample below is pulled from that codebase, not sketched from the docs.
The Supabase official quickstart shows you read-only CRUD with no auth. The Convex integration punts auth to a third party. A couple of strong blog posts each cover half of the real setup: one nails the cookie-correct server client but skips route protection, the other has the right security mental model but never mentions Supabase or RLS. Nobody puts the whole production path, including MFA and role-based access, in one place. That's what this post is, and it's not theoretical: it's the auth layer of a shipping product.
This is written against TanStack Start on Vite + Nitro with @supabase/ssr current and React 19, tested July 2026 against our own kit's 1.0.0 release. 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.
Why the quickstarts aren't enough
The gap isn't that good material doesn't exist, it's that no single source connects the pieces, and the pieces interlock:
- Supabase's official quickstart: canonical and correct as far as it goes, but it's read-only CRUD with RLS on by default and no auth. No
@supabase/ssrsession, nocreateServerFn, nobeforeLoad. - The Convex integration: ranks well, but has no RLS model at all and punts authentication to Clerk or WorkOS. Not a Supabase reference.
- The strongest Supabase-specific blog posts: get the cookie-correct
createServerClientright, which is the hardest part, but stop at "you're logged in." No route protection, no server-function authorization, no roles. - The best auth-guide posts: have the right security mental model (server function is the boundary, guard twice) but never touch Supabase,
@supabase/ssr, or RLS.
Merge the security model with the Supabase specifics, add protected routing, RBAC, MFA, and RLS, and you have a setup that survives contact with 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, plus route-level beforeLoad guards, plus 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 exactly why each one has to authorize itself. Nobody is doing it for them.
So you guard twice, and the two guards do different jobs:
beforeLoadguard = 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 user itself, or runs through middleware that does. This is the real boundary.
Our kit's Supabase variant encodes exactly this split, and it goes further than a single guard function: it's a composable RBAC middleware stack that every server function opts into by construction. That's the part most "here's how to add Supabase to TanStack Start" posts skip entirely, because they stop at "check if there's a user."
Setting up the @supabase/ssr server client
The server client is the load-bearing piece. It has to read auth cookies off the incoming request and write refreshed cookies back on the response, through TanStack Start's server surface, not Next.js helpers.
How to set up the Supabase SSR server client in TanStack Start:
- Create a Supabase project and grab the project URL and publishable (anon) key.
- Install
@supabase/ssrand@supabase/supabase-js. - Build a server client with
createServerClient, passinggetAll/setAllcookie accessors. - Source those cookies from
@tanstack/react-start/server(getRequest(),setCookie), parsed withparseCookieHeader. - Gate
cookieOptions.secureonNODE_ENV === 'production'so localhttp://localhostdev keeps working.
Here's the client, unchanged from packages/supabase/src/clients/server-client.server.ts in the kit:
import { createServerClient, parseCookieHeader } from '@supabase/ssr';import { getRequest, setCookie } from '@tanstack/react-start/server';import { type Database } from '../database.types';import { getSupabaseClientKeys } from '../get-supabase-client-keys';export function getSupabaseServerClient<GenericSchema = Database>() { const keys = getSupabaseClientKeys(); return createServerClient<GenericSchema>(keys.url, keys.publicKey, { cookieOptions: { secure: process.env.NODE_ENV === 'production', }, cookies: { getAll() { const header = getRequest().headers.get('cookie') ?? ''; return parseCookieHeader(header).map(({ name, value }) => ({ name, value: value ?? '', })); }, setAll(cookiesToSet) { cookiesToSet.forEach(({ name, value, options }) => setCookie(name, value, options), ); }, }, });}Two things matter here and both are easy to get wrong.
First, use the getAll/setAll cookie interface, not the older per-cookie get/set/remove. Supabase splits the session across several cookies (it chunks large tokens). The setAll shape hands you the full set to write in one pass. The single-cookie API is where the classic "only the last cookie survives" bug comes from, more on that in the footguns section.
Second, the cookie source is @tanstack/react-start/server, read straight off getRequest(). If you're following an older tutorial you'll see vinxi/h3 or a bespoke getWebRequest() shim. Those predate the move to the Vite plugin and Nitro. When something in a guide you're reading doesn't line up, check its date first.
Nothing here is wrapped in createServerOnlyFn. It doesn't need to be: getSupabaseServerClient lives inside .server.ts files, which TanStack Start's compiler already strips from the client bundle. The naming convention is the boundary.
Verified auth: getClaims(), not getUser(), and never getSession()
This is the part every other "Supabase on TanStack Start" post gets wrong, including an earlier draft of this one. The kit doesn't call getUser(). It calls getClaims().
getClaims vs getUser vs getSession, server-side
getSession() reads the session straight out of cookie storage without verifying it. On the server it's spoofable and must never gate access. getUser() always makes a network round trip to Supabase Auth to revalidate the JWT, on every single call. getClaims() is the current recommendation for server-side authorization: for projects on Supabase's asymmetric JWT signing keys it verifies the token locally (no network call), falling back to a network check only for legacy HS256 shared-secret projects. Verified like getUser(), without paying its latency tax on every request.
Concretely: cookies arrive with every request and a client can set cookie values to whatever they like. getSession() trusts them. getClaims() doesn't; it cryptographically verifies the JWT and hands you its claims (sub, email, aal, app_metadata, is_anonymous) without necessarily leaving the server process. That's the entire point, and it's why the kit standardizes on it everywhere a user needs verifying, not just in one place.
Here's requireUser, the function every authenticated code path in the kit calls through, from packages/supabase/src/require-user.ts:
export async function requireUser( client: SupabaseClient, options?: { verifyMfa?: boolean; next?: string; token?: string },): Promise<SuccessResult | AuthenticationErrorResult | MultiAuthErrorResult> { const { data, error } = await client.auth.getClaims(options?.token); if (!data?.claims || error) { return { data: null, error: new AuthenticationError(), redirectTo: getRedirectTo('/auth/sign-in', options?.next), }; } const { verifyMfa = true } = options ?? {}; if (verifyMfa) { const requiresMfa = await checkRequiresMultiFactorAuthentication(client); if (requiresMfa) { return { data: null, error: new MultiFactorAuthError(), redirectTo: getRedirectTo('/auth/verify', options?.next), }; } } const role = data.claims.app_metadata?.role; return { error: null, data: { id: data.claims.sub, email: data.claims.email, phone: data.claims.phone, aal: data.claims.aal, amr: data.claims.amr, is_anonymous: data.claims.is_anonymous || false, is_superadmin: role === 'super-admin' && data.claims.aal === 'aal2', }, };}One function, three jobs: verify the JWT, check whether the session needs to step up to MFA, and derive is_superadmin from claims (role === 'super-admin' && aal === 'aal2', so a compromised aal1 session can never pass an admin check even if the role claim is right). Every guard and every piece of protected server code in the kit goes through this, so "am I logged in" and "do I need MFA" are answered in exactly one place.
One fetch at the root, not one per route
The root route resolves the session once, in apps/web/src/routes/__root.tsx, and puts it on router context. Child routes read context.user instead of re-fetching:
export const Route = createRootRouteWithContext<RouterContext>()({ // Single token-refresh point: the cookie-writing server client verifies the // JWT via getClaims() and persists any rotated refresh cookie. Child routes // read context.user without re-fetching. This is UX-only — RLS still // enforces data access. beforeLoad: async () => { const [user, locale, theme] = await Promise.all([ fetchSession(), detectLocale(), getServerTheme(), ]); return { user, locale, theme }; }, // ...});fetchSession is a thin server function wrapping the same getClaims() call, returning null instead of throwing when there's no session so the app boots cleanly for anonymous visitors:
export const fetchSession = createServerFn({ method: 'GET' }).handler( async (): Promise<Session> => { const client = getSupabaseServerClient(); const { data, error } = await client.auth.getClaims(); if (error || !data?.claims) return null; const claims = data.claims; const role = claims.app_metadata?.role; return { id: claims.sub, email: claims.email, aal: claims.aal, amr: claims.amr, is_superadmin: role === 'super-admin' && claims.aal === 'aal2', is_anonymous: claims.is_anonymous ?? false, }; },);This is the piece that replaces proxy.ts/middleware.ts from the Next.js world: there's no file that runs before every route, so the root beforeLoad is the one place session resolution and refresh happen for navigation. Note the comment in the actual source: this is explicitly UX-only. It exists so the sign-in state renders correctly and instantly across the tree, not to authorize data access. That job belongs to RLS and the server-function middleware below, every time, no exceptions.
Protecting routes with beforeLoad
The authenticated layout route is where navigation actually gets locked down, and it does three things in sequence: check the session, check MFA assurance level, then resolve the active workspace. From apps/web/src/routes/_authenticated/route.tsx:
export const Route = createFileRoute('/_authenticated')({ beforeLoad: async ({ context, location }) => { if (!context.user) { throw redirect({ href: `${pathsConfig.auth.signIn}?next=${encodeURIComponent(location.href)}`, }); } const requiresMfa = await fetchRequiresMfa(); if (requiresMfa) { throw redirect({ href: pathsConfig.auth.verifyMfa }); } const workspace = await fetchWorkspace(); return { workspace }; }, component: AuthenticatedLayout,});fetchRequiresMfa is real, not aspirational: a user with MFA enrolled gets an aal1 session immediately after password sign-in, and checkRequiresMultiFactorAuthentication (packages/supabase/src/check-requires-mfa.ts) compares nextLevel against currentLevel from getAuthenticatorAssuranceLevel() to decide whether they need to step up before they see anything behind the gate. Most "add Supabase auth" tutorials never mention AAL2 step-up at all; it's not optional if you support MFA, and the kit wires it into the same guard as everything else.
The admin gate takes a different, more paranoid shape. From apps/web/src/routes/admin/route.tsx:
export const Route = createFileRoute('/admin')({ beforeLoad: ({ context, location }) => { if (!context.user) { throw redirect({ href: `${pathsConfig.auth.signIn}?next=${encodeURIComponent(location.href)}`, }); } if (!context.user.is_superadmin) { throw notFound(); } }, // ...});Anonymous visitors go to sign-in. Authenticated non-admins get a 404, not a redirect and not a 403. That's deliberate: a redirect or a "forbidden" page confirms /admin exists and gates on something; a 404 gives an attacker nothing to probe. Small detail, and one you won't get from copying a generic auth guard off a blog post.
One gotcha that bites people wrapping guard logic in try/catch: TanStack Router signals a redirect and a not-found by throwing. If you catch errors around a server-function call and log-and-swallow them, you'll eat the redirect too. Rethrow anything that's isRedirect() or isNotFound() before you handle a real error, which is exactly what the kit's errorMiddleware does at the function-middleware layer (next section).
The RBAC middleware stack: the part quickstarts skip entirely
This is the boundary that actually matters, and it's the step that doesn't exist in a bare @supabase/ssr setup because there's no server-function layer to hang it on.
Rather than paste an auth check into every handler, the kit factors authorization into composable function middleware, from packages/function-middleware/src/middleware.server.ts:
export const errorMiddleware = createMiddleware({ type: 'function' }).server( async ({ next }) => { try { return await next(); } catch (error) { if (isRedirect(error) || isNotFound(error)) throw error; const message = error instanceof Error ? error.message : 'Internal Server Error'; throw new Error(message); } },);export const authMiddleware = createMiddleware({ type: 'function' }).server( async ({ next }) => { const client = getSupabaseServerClient(); const auth = await requireUser(client); if (!auth.data) { throw redirect({ to: auth.redirectTo }); } return next({ context: { user: auth.data } }); },);errorMiddleware normalizes thrown errors to a clean message string, mirroring what next-safe-action does in our Next.js kit, while letting redirect()/notFound() control flow pass through untouched so navigation keeps working. authMiddleware is requireUser wired into the server-function pipeline: no user, no context.user, full stop.
On top of that base, the kit ships purpose-built gates for multi-tenant SaaS:
adminMiddleware: requirescontext.user.is_superadmin.teamAccountMiddleware: validates auuidaccountIdin the action input with Zod, then asserts membership via thehas_role_on_accountPostgres RPC, called through the RLS-authorized client.withMinRole(role): role-hierarchy check.has_role_on_accountdoes an exact match, which can't express "at least member," so this combineshas_more_elevated_roleORhas_same_role_hierarchy_levelto do it correctly.withFeaturePermission(permission): fine-grained feature gating via thehas_permissionRPC, for permissions narrower than a role (members.manage, for example).
These compose as literal tuples at the call site, not through a factory function, because TanStack Start's compiler needs to see a literal createServerFn(...) call to know which code is server-only and strip it from the client bundle:
export const authFunctionMiddleware = [errorMiddleware, authMiddleware] as const;export const teamAccountFunctionMiddleware = [errorMiddleware, teamAccountMiddleware] as const;export const removeMemberFunction = createServerFn({ method: 'POST' }) .middleware(teamAccountFunctionMiddleware) .validator(RemoveMemberSchema) .handler(async ({ data, context }) => { // context.accountId is guaranteed and membership-checked });Layer an extra gate by spreading a tuple, for example requiring a specific feature permission on top of team membership:
.middleware([...authFunctionMiddleware, withFeaturePermission('members.manage')])Guard the navigation with beforeLoad, guard the data with function middleware, and layer role and permission checks the same way you'd layer any other middleware. Two locks minimum, more when the action needs them.
RLS with the authenticated client, called through RPCs
Here's the payoff that makes Supabase and TanStack Start a genuinely good pairing: RLS applies automatically from your server functions, with no manual token juggling, and the kit's real feature code leans on Postgres RPCs instead of hand-filtering by account_id in TypeScript.
Because the @supabase/ssr server client carries the user's session from cookies, every query and RPC call it makes goes to Postgres as that user. Your RLS policies see auth.uid() and filter accordingly. fetchWorkspace, from apps/web/src/lib/server/active-workspace.functions.ts, is a real example, not a toy:
export const fetchWorkspace = createServerFn({ method: 'GET' }) .middleware([errorMiddleware]) .handler(async (): Promise<WorkspaceShape> => { const client = getSupabaseServerClient(); const auth = await requireUser(client); if (auth.error) { throw redirect({ href: auth.redirectTo }); } const [workspaceResult, accountsResult] = await Promise.all([ client.rpc('active_account_workspace'), client.from('user_accounts').select('*').order('name', { ascending: true }), ]); if (workspaceResult.error) throw workspaceResult.error; // ... return { account: workspaceResult.data[0], accounts: accountsResult.data, user: auth.data }; });No .eq('account_id', ...) anywhere in that function. active_account_workspace is a SECURITY DEFINER RPC that resolves the caller's active account server-side, and user_accounts is RLS-scoped to rows the calling user can actually see. Team member lists work the same way (get_active_account_members / get_active_account_invitations RPCs), and switching the active account goes through a set_active_account RPC that validates membership itself rather than trusting a client-supplied ID.
The pattern generalizes: for anything account-scoped, push the "which rows can this user see" logic into RLS policies and RPCs, and let the server function be a thin caller. It's less code, and it means a missing beforeLoad guard or a bug in teamAccountMiddleware still can't leak rows, the database refuses regardless. For policy patterns and the mistakes that make RLS slow or leaky, see Row Level Security in the TanStack Start Supabase kit and, more generally, Supabase RLS best practices.
The service-role (admin) client is the deliberate exception: it bypasses RLS entirely, for background jobs and privileged operations. Reach for it explicitly, validate authorization by hand every time, and keep it out of any code path a browser request can reach.
Request middleware: CSRF and security headers, not session refresh
TanStack Start's global request middleware, registered in apps/web/src/start.ts, is where cross-cutting concerns that apply to every request live: CSRF protection scoped to server functions, and security headers on every response.
const csrfMiddleware = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === 'serverFn',});const securityHeadersMiddleware = createMiddleware({ type: 'request' }).server( async ({ next }) => { const result = await next(); setResponseHeader('X-Frame-Options', 'DENY'); setResponseHeader('X-Content-Type-Options', 'nosniff'); setResponseHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); if (process.env.NODE_ENV === 'production') { setResponseHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); } return result; },);export const startInstance = createStart(() => ({ requestMiddleware: [csrfMiddleware, securityHeadersMiddleware],}));Worth being precise here, because it's easy to assume this is also where you'd refresh the Supabase session on every request, the way middleware.ts does in Next.js. It isn't. In this kit, session verification and refresh happen once, at the root beforeLoad, via fetchSession(), and the cookie-writing server client persists any rotated token through setAll at that point. start.ts stays focused on CSRF and headers. If your app needs the session available to genuinely global, non-route logic, that's a legitimate reason to add a request-middleware session check, but it's an addition to this pattern, not the default the kit ships with.
Production footguns nobody collects in one place
These turn a working local demo into a support ticket. Dodge them on the way in instead of debugging them at midnight.
- The multi-cookie collapse. Set Supabase auth cookies one at a time and you'll lose all but the last, because Supabase writes several chunked cookies and a naive single-cookie setter clobbers them. The fix is the
getAll/setAllinterface shown above, which writes the whole set in one pass. This is the number-one reason "login works but the session vanishes on refresh." (TanStack Start issues #5464, #2680.) - Can't read a cookie you just set in the same request. Inside one
createServerFninvocation, a cookie written viasetCookieisn't visible to a subsequent read in that same request. Don't set-then-read; carry the value in a local variable instead. (Issue #5615.) getUser()on every guard, on every navigation. Even before you considergetClaims(), callinggetUser()from multiple guards on the same navigation multiplies network round trips to Supabase Auth for no benefit. Resolve the session once, at the root, the wayfetchSessiondoes, and pass it down through router context.- Auth cookie doesn't persist in Safari. Safari's stricter cookie handling can drop cookies with the wrong
SameSite/Secure/domain settings. Check your cookie options and your local vs production domain setup if sessions survive in Chrome but not Safari. (Issue #3492.) - Forgetting the AAL2 check entirely. If you support MFA (and you should, for anything handling money or sensitive data), a session that's merely "authenticated" isn't the same as one that's cleared its second factor. Skip the
aal/amrcheck and anaal1session sails past guards that assume MFA already happened.
None of these are exotic. They're the difference between a quickstart that demos well and a setup you can put real users on.
Fast feedback for the auth code you just wrote: oxlint, oxfmt, TypeScript 7
None of the guard, middleware, or RLS code above is useful if you can't verify it fast. Auth code is exactly the kind of thing you iterate on: tighten a guard, add a permission check, adjust a claim shape, then check it didn't break anything. The kit's toolchain is built for that loop, and it's not the usual eslint + prettier + tsc stack.
package.json at the repo root wires up oxlint for linting and oxfmt for formatting, both from the Rust-based oxc toolchain, and pins typescript@7.0.2, the Go-ported compiler (shipped as per-platform native binaries, @typescript/typescript-darwin-arm64 and friends, instead of the old JS-hosted tsc). The combined check is one command:
"healthcheck": "oxlint --fix && oxfmt && pnpm run typecheck && manypkg fix"The practical effect: linting and formatting a monorepo this size finishes in a fraction of the time the ESLint/Prettier equivalent takes, and tsc --noEmit against TypeScript 7's native compiler checks the type-level guarantees in this post, that context.user is narrowed after a guard, that requireUser's discriminated union is exhaustively handled, in seconds instead of tens of seconds on a cold run. That's not just a nicer pnpm dev experience. It's the difference between a coding agent (Claude Code included) running typecheck and lint:fix after every edit to your auth middleware and actually getting fast, trustworthy feedback, versus a check so slow it gets skipped. Fast, deterministic verification is what makes it safe to let an agent touch authMiddleware or a withFeaturePermission gate at all.
The MakerKit TanStack Start Supabase kit
You can hand-roll everything above, and now you know exactly what "everything" is: a cookie-correct server client, getClaims()-verified sessions resolved once at the root, beforeLoad guards for navigation and MFA step-up, a composable RBAC middleware stack for server functions, and RLS-backed RPCs for data. That's not a small amount of plumbing to get right and keep right as the app grows.
Our TanStack Start SaaS kit ships this exact architecture today, on real Supabase Auth, not a hypothetical variant. Every code sample in this post is pulled directly from that codebase: getSupabaseServerClient, requireUser, the authMiddleware/teamAccountMiddleware/withMinRole/withFeaturePermission stack, MFA-aware guards, and the RLS-scoped RPCs behind team workspaces. It ships alongside Drizzle and Prisma variants of the same kit for teams who want a different data layer with the same TanStack Start foundation; see the installation docs to get started, or read Introducing the TanStack Start SaaS Starter Kit for the full pitch across all three.
Quick Recommendation
This production setup is best for:
- Teams building a real SaaS on TanStack Start who need auth that survives direct endpoint calls, not just a demo.
- Anyone who wants Supabase RLS as a real second line of defense behind app-level checks, plus roles, permissions, and MFA out of the box.
Skip the full setup if:
- You're building a throwaway prototype or an internal read-only dashboard with no private data.
- You haven't yet decided on the framework; read the hub comparison first.
Our pick: Cookie-correct @supabase/ssr server client, getClaims() resolved once at the root for every authorization decision, beforeLoad for navigation and MFA step-up, and function middleware plus RLS-backed RPCs for data. Guard navigation once, guard data with a real middleware stack, and let Postgres be the backstop.
Frequently Asked Questions
getClaims(), getUser(), or getSession() on the server in TanStack Start?
Is a beforeLoad guard enough to secure my data?
Does RLS work automatically from a TanStack Start server function?
Does the MakerKit TanStack Start kit support Supabase Auth?
Do I need a middleware.ts file like Next.js for Supabase sessions?
Why does my Supabase session disappear after a page refresh?
How does role-based access control work with Supabase in this kit?
What lint, format, and typecheck tooling does the kit use?
Next steps
You now have the whole production path: cookie-correct server client, verified getClaims(), double-guarded routes and data, RBAC middleware, MFA step-up, and RLS as the backstop. If you're weighing the framework itself, read TanStack Start vs Next.js. For RLS depth, go to Supabase RLS best practices or the kit's own Row Level Security docs. If you picked Better Auth instead of Supabase Auth, the equivalent wiring is covered in Better Auth on TanStack Start. And if you'd rather start from a kit that already ships this architecture on Supabase, Drizzle, or Prisma, take a look at the TanStack Start SaaS kit.