# TanStack Start + Supabase Auth: The Production Setup (2026)

> How to add Supabase Auth to TanStack Start for production: an @supabase/ssr server client that handles cookies correctly, getClaims() for verified JWTs, beforeLoad route guards, RBAC middleware for server functions, and RLS. Code from our TanStack Start Supabase kit.

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

---

**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](/tanstack-start).**

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](/blog/tutorials/tanstack-start-vs-nextjs) or [What Is TanStack Start?](/blog/tutorials/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/ssr` session, no `createServerFn`, and no `beforeLoad`.
- **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 `createServerClient` cookie 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:

- **`beforeLoad` guard (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:**

1. Create a Supabase project and copy the project URL and publishable (anon) key.
2. Install `@supabase/ssr` and `@supabase/supabase-js`.
3. Build a server client with `createServerClient`, passing `getAll`/`setAll` cookie accessors.
4. Source those cookies from `@tanstack/react-start/server` (`getRequest()`, `setCookie`), parsed with `parseCookieHeader`.
5. Set `cookieOptions.secure` only when `NODE_ENV === 'production'` so local `http://localhost` development keeps working.

This is the client, unchanged from `packages/supabase/src/clients/server-client.server.ts` in the kit:

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

{% callout title="getClaims vs getUser vs getSession, server-side" type="warning" %}
`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.
{% /callout %}

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

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

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

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

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

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

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

- **`adminMiddleware`** requires `context.user.is_superadmin`.
- **`teamAccountMiddleware`** validates a `uuid` `accountId` in the input with Zod, then checks membership with the `has_role_on_account` Postgres RPC through the RLS-authorized client.
- **`withMinRole(role)`** checks the role hierarchy. `has_role_on_account` only matches an exact role, so it can't express "member or higher". `withMinRole` combines `has_more_elevated_role` and `has_same_role_hierarchy_level` to answer that.
- **`withFeaturePermission(permission)`** checks a single permission, such as `members.manage`, with the `has_permission` RPC.

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:

```ts
export const authFunctionMiddleware = [errorMiddleware, authMiddleware] as const;
export const teamAccountFunctionMiddleware = [errorMiddleware, teamAccountMiddleware] as const;
```

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

```ts
.middleware([...authFunctionMiddleware, withFeaturePermission('members.manage')])
```

The [function middleware docs](/docs/tanstack-supabase/development/function-middleware) 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:

```ts
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](/docs/tanstack-supabase/security/row-level-security) and [Supabase RLS best practices](/blog/tutorials/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:

```ts
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](/docs/tanstack-supabase/data-fetching/csrf-protection) 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`/`setAll` interface 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 `createServerFn` call, a cookie written with `setCookie` isn'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.** Calling `getUser()` from several guards during one navigation adds a Supabase Auth network request per call. Resolve the session once at the root, as `fetchSession` does, 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`/`amr` check, an `aal1` session 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](https://oxc.rs) toolchain, and TypeScript 7, the Go port of the compiler, for type checking. One root script runs all of them:

```json
"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](/blog/changelog/oxc-migration)). 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](/tanstack-start) 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](/docs/tanstack-supabase/installation/introduction), or read [Introducing the TanStack Start SaaS Starter Kit](/blog/changelog/introducing-tanstack-start-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](/blog/tutorials/tanstack-start-vs-nextjs) 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.

{% faq
   title="Frequently Asked Questions"
   items=[
     {"question": "getClaims(), getUser(), or getSession() on the server in TanStack Start?", "answer": "Use getClaims(). It cryptographically verifies the JWT, and for projects on Supabase's asymmetric JWT signing keys it does so locally without a network round trip, falling back to a network check only for legacy HS256 projects. getUser() is also verified but always makes a network call on every invocation. getSession() reads the session out of cookie storage without verifying it at all, which is spoofable on the server and must never gate access."},
     {"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, typically through function middleware. Guard navigation with beforeLoad, and guard data inside the server function with an auth middleware stack plus RLS."},
     {"question": "Does RLS work automatically from a TanStack Start server function?", "answer": "Yes. The @supabase/ssr server client reads the user's session from cookies and sends that access token with every query and RPC call, so RLS policies see auth.uid() and filter rows automatically. You don't pass a token or set an Authorization header. The service-role client is the exception: it bypasses RLS by design, so validate authorization manually when you use it."},
     {"question": "Does the MakerKit TanStack Start kit support Supabase Auth?", "answer": "Yes, as of the kit's 1.0.0 release. The Supabase variant ships an @supabase/ssr server client using getAll/setAll cookies, getClaims()-based session resolution, beforeLoad guards with MFA step-up, an RBAC middleware stack (authMiddleware, teamAccountMiddleware, withMinRole, withFeaturePermission), and RLS-scoped Postgres RPCs for team workspaces. Drizzle and Prisma variants share the same TanStack Start foundation."},
     {"question": "Do I need a middleware.ts file like Next.js for Supabase sessions?", "answer": "No. TanStack Start has no middleware.ts. Global request-level concerns like CSRF and security headers go in the request middleware registered in start.ts. Session verification and refresh happen once, in the root route's beforeLoad, via a getClaims() call whose cookie-writing server client persists any rotated token. Route-level protection lives in beforeLoad, and data-level protection lives in server-function middleware plus RLS."},
     {"question": "Why does my Supabase session disappear after a page refresh?", "answer": "Usually because only one of Supabase's chunked auth cookies was saved: a single-cookie setter keeps only the last one. Use the getAll/setAll cookie interface on createServerClient, not the older get/set/remove. If it only breaks in Safari, also check the SameSite, Secure, and domain cookie settings."},
     {"question": "How does role-based access control work with Supabase in this kit?", "answer": "Through Postgres RPCs called from the RLS-authorized server client, wrapped in function middleware. teamAccountMiddleware checks membership via has_role_on_account, withMinRole(role) combines has_more_elevated_role and has_same_role_hierarchy_level for at-least-this-role checks, and withFeaturePermission(permission) checks fine-grained permissions via has_permission. Compose them onto a server function as middleware tuples."},
     {"question": "What lint, format, and typecheck tooling does the kit use?", "answer": "oxlint for linting and oxfmt for formatting, both from the Rust-based Oxc toolchain, plus TypeScript 7 (the Go port of the compiler) for type checking. A single healthcheck script runs oxlint --fix, oxfmt, typecheck, and manypkg fix, fast enough to run after every change to auth code."}
   ]
/%}

### Next steps

- Choosing a framework: [TanStack Start vs Next.js](/blog/tutorials/tanstack-start-vs-nextjs).
- More on RLS: [Supabase RLS best practices](/blog/tutorials/supabase-rls-best-practices) and the kit's [Row Level Security docs](/docs/tanstack-supabase/security/row-level-security).
- Using Better Auth instead of Supabase Auth: [Better Auth on TanStack Start](/blog/tutorials/better-auth-tanstack-start).
- Choosing an ORM for the non-Supabase variants: [Drizzle or Prisma for TanStack Start](/blog/tutorials/tanstack-start-drizzle-vs-prisma).
- Starting from a kit that already ships this setup on Supabase, Drizzle, or Prisma: the [TanStack Start SaaS kit](/tanstack-start).
