To run Supabase Auth in production on TanStack Start you need four pieces: an @supabase/ssr server client that reads and writes cookies through @tanstack/react-start/server, supabase.auth.getClaims() to verify the JWT on every request, beforeLoad guards for routes plus authorization middleware on every server function, and RLS policies with Postgres RPCs so data access doesn't depend on hand-written account_id filters. Every code sample below comes from the Supabase variant of our TanStack Start SaaS kit.
Most TanStack Start Supabase auth tutorials cover one or two of these pieces. This guide covers all four, plus MFA step-up and role-based access control, as they run in a shipping codebase.
We wrote it against TanStack Start on Vite and Nitro, a current @supabase/ssr, and React 19, and tested it in July 2026 against the kit's 1.0.0 release. TanStack Start changes quickly, so check the date on any tutorial you copy from, this one included.
If you're still choosing a framework, read TanStack Start vs Next.js or What Is TanStack Start? first.
What the existing guides cover, and what they leave out
No single source connects the pieces, and each piece depends on the others:
- Supabase's official TanStack Start quickstart is correct but covers read-only queries with RLS enabled and no auth. There's no
@supabase/ssrsession, nocreateServerFn, and nobeforeLoad. - The Convex integration has no RLS model and hands authentication to Clerk or WorkOS, so it isn't a Supabase reference.
- Supabase-specific blog posts usually get the
createServerClientcookie handling right, which is the hardest part, then stop once the user is signed in. They skip route protection, server-function authorization, and roles. - General TanStack Start auth guides have the right security model (the server function is the boundary; check auth in both places) but don't cover Supabase,
@supabase/ssr, or RLS.
The rest of this post combines the security model with the Supabase specifics, then adds protected routes, RBAC, MFA, and RLS.
Where the security boundary is: server functions, not routes
A beforeLoad guard controls navigation. It does not protect data. Authorization for data has to happen inside each server function, and RLS enforces it again in Postgres.
TanStack Start has no middleware.ts and no Next.js-style proxy in front of every route. You get a global request middleware that you register yourself, route-level beforeLoad guards, and server functions. A beforeLoad guard decides whether a user can see a screen. It does nothing to protect the endpoints that screen calls.
Server functions (createServerFn) are POST endpoints. Anyone who can reach your app can call one directly, without loading the route that normally calls it. They go through the request pipeline like any other POST, so each one has to check authorization itself.
That means you check auth in two places, for two different reasons:
beforeLoadguard (UX). Redirects anonymous users to sign-in before a protected screen renders. It improves the experience; it is not a security control.- Server function auth (security). Every server function that reads or writes private data verifies the user, directly or through middleware. This is the actual boundary.
The kit's Supabase variant is built on this split. Instead of a single guard function, it uses a composable RBAC middleware stack that each server function declares. Most "add Supabase to TanStack Start" posts leave this out because they stop at checking whether a user exists.
Setting up the @supabase/ssr server client
Everything else depends on the server client. It reads auth cookies from the incoming request and writes refreshed cookies to the response, using TanStack Start's server APIs rather than Next.js helpers.
How to set up the Supabase SSR server client in TanStack Start:
- Create a Supabase project and copy 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. - Set
cookieOptions.secureonly whenNODE_ENV === 'production'so localhttp://localhostdevelopment keeps working.
This is 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 details in this file are easy to get wrong.
Use the getAll/setAll cookie interface, not the older per-cookie get/set/remove. Supabase splits large session tokens across several cookies. setAll receives the full set and writes it in one pass. The single-cookie API is the source of the "only the last cookie survives" bug described in the production problems section below.
Read cookies from @tanstack/react-start/server via getRequest(). Older tutorials use vinxi/h3 or a custom getWebRequest() shim. Those predate TanStack Start's move to a Vite plugin on Nitro. If a guide's imports don't match the current packages, check when it was written.
The client isn't wrapped in createServerOnlyFn, and it doesn't need to be. getSupabaseServerClient lives in a .server.ts file, and TanStack Start's compiler already excludes those files from the client bundle.
Verifying the user: getClaims(), not getUser(), and never getSession()
On the server, verify the session with getClaims(). The kit uses it everywhere a user needs verifying and doesn't call getUser(). (An earlier draft of this post got this wrong, as do most Supabase-on-TanStack-Start tutorials.)
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 its network round trip on every request.
Cookies arrive with every request, and a client can set them to any value. getSession() trusts them. getClaims() verifies the JWT signature and returns its claims (sub, email, aal, app_metadata, is_anonymous), usually without a network call.
Every authenticated code path in the kit goes through requireUser, 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', }, };}requireUser does three things: it verifies the JWT, checks whether the session must step up to MFA, and derives is_superadmin from the claims. The admin check requires both role === 'super-admin' and aal === 'aal2', so a stolen aal1 session fails the admin check even when the role claim is correct. Because every guard and protected server function calls requireUser, the "is this user signed in" and "does this user need MFA" logic lives in one file.
Resolve the session once, in the root 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 fetching it again:
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 small server function around the same getClaims() call. It returns null instead of throwing when there's no session, so the app renders normally 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 replaces proxy.ts/middleware.ts from Next.js. No file runs before every route, so the root beforeLoad is where session resolution and token refresh happen during navigation. As the source comment says, this is for UX: it lets the whole route tree render the correct signed-in state. It does not authorize data access. RLS and the server-function middleware below do that.
Protecting routes with beforeLoad
The authenticated layout route runs three checks in order: session, MFA assurance level, then 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,});A user with MFA enrolled gets an aal1 session right after password sign-in. fetchRequiresMfa calls checkRequiresMultiFactorAuthentication (packages/supabase/src/check-requires-mfa.ts), which compares nextLevel with currentLevel from getAuthenticatorAssuranceLevel() and redirects to the verification screen if they differ. If your app supports MFA, this step-up check is required; without it, an aal1 session reaches pages that assume the second factor was verified.
The admin route is stricter. 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 are sent to sign-in. Signed-in users who aren't super admins get a 404 rather than a redirect or a 403. A redirect or a "forbidden" page confirms that /admin exists; a 404 doesn't.
If you wrap guard logic in try/catch, rethrow redirects. TanStack Router signals a redirect or a not-found by throwing. A catch that logs and swallows errors around a server-function call will also swallow the redirect. Rethrow anything where isRedirect() or isNotFound() is true before handling real errors. The kit's errorMiddleware does this at the function-middleware layer (next section).
RBAC middleware for server functions
Each server function declares its authorization as a middleware tuple, so no handler contains a hand-written auth check. A plain @supabase/ssr setup has no equivalent, because it has no server-function layer to attach checks to.
The base middleware is in 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 converts thrown errors to a plain message string, so raw Postgrest errors never reach the browser, and lets redirect()/notFound() pass through so navigation still works. It plays the same role as next-safe-action in our Next.js kit. authMiddleware runs requireUser inside the server-function pipeline: if there's no verified user, the handler never runs and context.user is never set.
The kit adds four gates for multi-tenant SaaS on top of these:
adminMiddlewarerequirescontext.user.is_superadmin.teamAccountMiddlewarevalidates auuidaccountIdin the input with Zod, then checks membership with thehas_role_on_accountPostgres RPC through the RLS-authorized client.withMinRole(role)checks the role hierarchy.has_role_on_accountonly matches an exact role, so it can't express "member or higher".withMinRolecombineshas_more_elevated_roleandhas_same_role_hierarchy_levelto answer that.withFeaturePermission(permission)checks a single permission, such asmembers.manage, with thehas_permissionRPC.
The middleware is composed as literal tuples at the call site, not built by a factory function. TanStack Start's compiler needs a literal createServerFn(...) call to recognize server-only code 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 });To add a gate, spread a tuple and append it. This example requires a feature permission on top of authentication:
.middleware([...authFunctionMiddleware, withFeaturePermission('members.manage')])The function middleware docs list every tuple and gate. The rule is: beforeLoad for navigation, function middleware for data, and extra role or permission gates when an action needs them.
RLS from server functions, through RPCs
RLS applies automatically to queries made from TanStack Start server functions, with no manual token handling. The kit's feature code calls Postgres RPCs instead of filtering by account_id in TypeScript.
The @supabase/ssr server client carries the user's session from cookies, so every query and RPC runs in Postgres as that user, and RLS policies see the correct auth.uid(). fetchWorkspace, from apps/web/src/lib/server/active-workspace.functions.ts, shows the pattern:
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 }; });The function contains no .eq('account_id', ...). active_account_workspace is a SECURITY DEFINER RPC that resolves the caller's active account in Postgres, and user_accounts is RLS-scoped to rows the caller can see. Team member lists use the same approach (get_active_account_members and get_active_account_invitations), and switching accounts goes through a set_active_account RPC that validates membership itself instead of trusting an ID from the client.
For any account-scoped data, put the "which rows can this user see" logic in RLS policies and RPCs, and keep the server function thin. You write less code, and a missing beforeLoad guard or a bug in teamAccountMiddleware still can't leak rows because Postgres rejects the query. For policy patterns and common RLS performance and security mistakes, see Row Level Security in the TanStack Start Supabase kit and Supabase RLS best practices.
The service-role (admin) client is the exception. It bypasses RLS and is meant for background jobs and privileged operations. Use it explicitly, check authorization manually 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, handles concerns that apply to every request. In the kit that means CSRF protection for 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],}));Coming from Next.js, you might expect to refresh the Supabase session here on every request, as middleware.ts does. The kit doesn't. Session verification and refresh happen once, in the root beforeLoad, through fetchSession(), and the server client's setAll writes any rotated token at that point. If you need the session in global logic that isn't tied to a route, adding a session check to request middleware is reasonable, but it's an addition to what the kit ships. See the CSRF protection docs for how the CSRF filter works.
Common TanStack Start Supabase auth problems in production
These five issues are the ones that break a setup that worked locally:
- Only the last auth cookie survives. Supabase writes several chunked cookies. A single-cookie setter overwrites them one after another, leaving only the last. Use the
getAll/setAllinterface shown above. This is the most common cause of "login works but the session disappears on refresh." (TanStack Start issues #5464, #2680.) - A cookie set in a request can't be read in the same request. Inside one
createServerFncall, a cookie written withsetCookieisn't visible to a later read. Keep the value in a local variable instead of setting and then reading it. (Issue #5615.) getUser()in every guard on every navigation. CallinggetUser()from several guards during one navigation adds a Supabase Auth network request per call. Resolve the session once at the root, asfetchSessiondoes, and pass it down through router context.- The auth cookie doesn't persist in Safari. Safari drops cookies with incorrect
SameSite,Secure, or domain settings more often than Chrome does. If sessions work in Chrome but not Safari, check your cookie options and your local versus production domains. (Issue #3492.) - No AAL2 check. With MFA enabled, an authenticated session is not the same as one that has passed the second factor. Without an
aal/amrcheck, anaal1session passes guards that assume MFA already happened.
Checking auth changes quickly: oxlint, oxfmt, and TypeScript 7
When you change a guard, a permission check, or a claim shape, you want to know right away whether anything broke. The kit uses oxlint for linting and oxfmt for formatting, both from the Rust-based Oxc toolchain, and TypeScript 7, the Go port of the compiler, for type checking. One root script runs all of them:
"healthcheck": "oxlint --fix && oxfmt && pnpm run typecheck && manypkg fix"The type checker enforces several guarantees in this post: context.user is narrowed after a guard, and requireUser's discriminated union has to be handled before you read data. When we moved our Drizzle and Prisma kits from ESLint and Prettier to Oxc in version 1.4, linting the monorepo went from about 10 seconds to about 300 ms (details). Checks that fast are practical to run after every edit, including when a coding agent such as Claude Code is editing authMiddleware or a withFeaturePermission gate.
The MakerKit TanStack Start Supabase kit
Everything above can be built by hand: a server client with correct cookie handling, getClaims() sessions resolved once at the root, beforeLoad guards for navigation and MFA step-up, RBAC middleware for server functions, and RLS-backed RPCs for data. It's a lot of code to get right and keep right as the app grows.
Our TanStack Start SaaS kit ships this architecture on Supabase Auth: getSupabaseServerClient, requireUser, the authMiddleware/teamAccountMiddleware/withMinRole/withFeaturePermission stack, MFA-aware guards, and the RLS-scoped RPCs behind team workspaces. Drizzle and Prisma variants use the same TanStack Start foundation with Better Auth and a different data layer. Start with the installation docs, or read Introducing the TanStack Start SaaS Starter Kit for an overview of all three variants.
Quick Recommendation
Use this setup if:
- You're building a SaaS on TanStack Start and your server functions must stay secure when called directly.
- You want Supabase RLS as a second layer behind app-level checks, plus roles, permissions, and MFA.
Skip it if:
- You're building a throwaway prototype or an internal read-only dashboard with no private data.
- You haven't chosen a framework yet; read the TanStack Start vs Next.js comparison first.
Our pick: an @supabase/ssr server client using getAll/setAll, 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. Postgres makes the final access decision.
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
- Choosing a framework: TanStack Start vs Next.js.
- More on RLS: Supabase RLS best practices and the kit's Row Level Security docs.
- Using Better Auth instead of Supabase Auth: Better Auth on TanStack Start.
- Choosing an ORM for the non-Supabase variants: Drizzle or Prisma for TanStack Start.
- Starting from a kit that already ships this setup on Supabase, Drizzle, or Prisma: the TanStack Start SaaS kit.