Skip to content
v3.0.1 — MCP OAuth, organizations, passkeys, SSR adaptersChangelog
Convex Auth
Esc
↑↓navigate↵open⌘Jpreview
On this page

TanStack Start

SSR auth for TanStack Start with HttpOnly cookie sessions.

@vortex-api/convex-auth/tanstack-start and @vortex-api/convex-auth/tanstack-start/server are the TanStack Start SSR adapter. Sessions live in app-origin HttpOnly cookies; a request middleware lands OAuth/magic-link redirects, refreshes sessions near expiry, and serves the intent-based /api/auth proxy. It implements the SSR auth contract.

A working example lives in examples/tanstack-start.

Install

pnpm add @vortex-api/convex-auth convex

Point the app at your Convex deployment and wire the backend (component mount, convex/auth.ts, convex/http.ts) per Installation:

echo 'VITE_CONVEX_URL=https://<your-deployment>.convex.cloud' >> .env.local

Request middleware

Register convexAuthRequestMiddleware in src/start.ts:

// src/start.ts
import { createStart } from "@tanstack/react-start";
import { convexAuthRequestMiddleware } from "@vortex-api/convex-auth/tanstack-start/server";
import { api } from "../convex/_generated/api";

export const startInstance = createStart(() => ({
  requestMiddleware: [convexAuthRequestMiddleware({ actions: api.auth })],
}));

The middleware intercepts auth requests and the /api/auth proxy internally — no separate route wiring is needed.

Options

Option Default Description
actions required The api.auth object re-exporting convexAuth actions.
convexUrl CONVEX_URL → VITE_CONVEX_URL → NEXT_PUBLIC_CONVEX_URL Convex deployment URL.
apiRoute /api/auth Path that proxies auth actions.
cookieConfig.maxAge null (session cookie) Cookie lifetime in seconds. Must be null or a positive number.
requireLandingVerifier true Require OAuth/magic-link landings to carry a verifier param matching the cookie minted at flow start. Set false only for deployments that predate verifier threading.
verbose false Debug logging.

Server state + provider

Resolve auth state in the root route’s beforeLoad via a server function, then pass it to ConvexAuthTanstackStartProvider:

// src/lib/auth-server.ts
import { createServerFn } from "@tanstack/react-start";
import { getRequest } from "@tanstack/react-start/server";
import {
  getAuthServerState,
  getConvexAuthSession,
} from "@vortex-api/convex-auth/tanstack-start/server";
import { api } from "../../convex/_generated/api";

/**
 * Server-resolved auth state for the root route's `beforeLoad` — seeds the
 * client provider and feeds route guards. Revocation-aware.
 */
export const getAuthState = createServerFn({ method: "GET" }).handler(async () => {
  return await getAuthServerState(getRequest(), { actions: api.auth });
});

/**
 * Verified session for protected loaders/server functions that don't want
 * the full middleware context.
 */
export const getSession = createServerFn({ method: "GET" }).handler(async () => {
  return await getConvexAuthSession(getRequest(), { actions: api.auth });
});
// src/routes/__root.tsx
import { HeadContent, Outlet, Scripts, createRootRoute } from "@tanstack/react-router";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { ConvexAuthTanstackStartProvider } from "@vortex-api/convex-auth/tanstack-start";
import { api } from "../../convex/_generated/api";
import { getAuthState } from "../lib/auth-server";

const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string);

export const Route = createRootRoute({
  beforeLoad: async () => ({
    auth: await getAuthState(),
  }),
  component: RootComponent,
});

function RootComponent() {
  const { auth } = Route.useRouteContext();

  return (
    <html lang="en">
      <body>
        <ConvexProvider client={convex}>
          <ConvexAuthTanstackStartProvider actions={api.auth} serverState={auth}>
            <Outlet />
          </ConvexAuthTanstackStartProvider>
        </ConvexProvider>
        <Scripts />
      </body>
    </html>
  );
}

Server helpers

From @vortex-api/convex-auth/tanstack-start/server:

Helper Returns Notes
getAuthServerState(request, { actions }) ConvexAuthServerState Server-resolved state for the provider’s serverState prop.
getConvexAuthSession(request, { actions }) VerifiedSession Verified session — revocation-aware.
getConvexAuthToken(request) string | null Raw session token.
convexAuthCookieState(request) { hasSessionCookie, tokenExpired } Optimistic boundary check — revocation-blind, pre-filtering only.
convexAuthProxyHandler(options) (request) => Promise<Response> Factory — standalone proxy handler if you mount /api/auth outside the middleware.

Route guards are UX, not security

beforeLoad redirects and convexAuthCookieState() checks shape navigation; they are not authorization. Always verify the session at the data boundary (getConvexAuthSession, verifySession, or ctx.auth.getUserIdentity() inside Convex functions).

Error params

Rejected landings redirect to /?error=landing_verifier_mismatch (verifier cookie absent or mismatched) or /?error=cross_origin (credentialed cross-origin request). Surface these in your UI — see the SSR auth contract for the full semantics.

Sign-in UI

Client components use the React client — sign-in calls mint the session through the /api/auth proxy into HttpOnly cookies:

// src/routes/sign-in.tsx
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { FormEvent, useState } from "react";
import { useConvexAuthClient } from "@vortex-api/convex-auth/react";

export const Route = createFileRoute("/sign-in")({ component: SignInPage });

function SignInPage() {
  const authClient = useConvexAuthClient();
  const router = useRouter();

  const [error, setError] = useState<string | null>(null);

  const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    const result = await authClient.signIn.email({
      email: String(data.get("email")),
      password: String(data.get("password")),
    });
    if (result.error) {
      setError(result.error.message ?? "Sign in failed");
      return;
    }
    // 2FA-enrolled accounts resolve without a session — show the TOTP step.
    if (result.data?.twoFactorRedirect) return;
    // Re-run beforeLoad/loaders so route context picks up the fresh session.
    await router.invalidate();
    await router.navigate({ to: "/dashboard" });
  };

  const onGithub = async () => {
    const result = await authClient.signIn.social({ provider: "github" });
    // social() returns the authorization URL — it does not navigate for you.
    if (result.data?.url) window.location.href = result.data.url;
  };

  return (
    <form onSubmit={onSubmit}>
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <button type="submit">Sign in</button>
      <button type="button" onClick={onGithub}>
        Continue with GitHub
      </button>
      {error ? <p role="alert">{error}</p> : null}
    </form>
  );
}

The full form (sign-up, redirect search param, guest) is in examples/tanstack-start/src/routes/sign-in.tsx. See the React guide for the complete hooks API.

Example app

examples/tanstack-start is a full SSR app on the adapter — the root route resolves the verified session in beforeLoad and seeds ConvexAuthTanstackStartProvider, a pathless _authed layout guards navigation, and protected createServerFns declare convexAuthFunctionMiddleware.

cd examples/tanstack-start
pnpm install
pnpm dlx convex dev   # local backend on :3212
pnpm run dev          # app on :3200

Next steps

Was this page helpful?