convex-logto

Auth in your app

Render and route on auth state with a component, a hook, or a route guard, and store your own data for a user.

There is no special concept to learn. convex-logto plugs into Convex's normal auth, so you get all the standard ways to react to auth state:

  • useLogtoAuth(): a hook returning { isAuthenticated, isLoading, user, signIn, signOut }.
  • Convex's <Authenticated> / <Unauthenticated> / <AuthLoading> and useConvexAuth() from convex/react, which work unchanged.

isAuthenticated / isLoading from useLogtoAuth() come from useConvexAuth(), so they're authoritative. isAuthenticated is true only once Convex has accepted the token. Gate on these and there's no flash of the wrong state.

The snippets import from convex-logto/react-session. In bridge mode import from convex-logto/react instead; the five fields above are the same. Session mode's hook adds signOutEverywhere() and the device list, covered in Session mode.

Storing data for a user

You usually don't need a users table. Every Convex function already has the signed-in user's identity from the token, and identity.subject is their stable Logto id, so attach your data to your own tables, keyed by it:

convex/posts.ts
import { v } from "convex/values";
import { mutation } from "./_generated/server";

export const createPost = mutation({
  args: { body: v.string() },
  handler: async (ctx, { body }) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("unauthenticated");
    await ctx.db.insert("posts", { body, authorId: identity.subject }); // the link
  },
});

The user isn't a row; their data points at identity.subject. That covers "the current user and the things they own."

Reach for a synced users table only when you need users who aren't the current caller (an admin list, another user's display name) or Logto-owned profile and lifecycle fields mirrored into Convex (email, name, suspended, deleted). App-owned data the token doesn't carry, such as preferences, plan, and feature flags, can stay in your own tables keyed by identity.subject. See Webhook sync.

Declarative gating

The simplest app shell. Each branch renders only in its state; queries inside <Authenticated> never run before auth settles.

import { Authenticated, Unauthenticated, AuthLoading } from "convex/react";

function App() {
  return (
    <>
      <AuthLoading>
        <Spinner />
      </AuthLoading>
      <Unauthenticated>
        <SignInButton />
      </Unauthenticated>
      <Authenticated>
        <Dashboard />
      </Authenticated>
    </>
  );
}

The hook

useLogtoAuth() is plain state. Call it at the top of your component, then use the values and signIn / signOut anywhere: in return, event handlers, effects, or conditionals.

import { useLogtoAuth } from "convex-logto/react-session";

function AuthButton() {
  const { isAuthenticated, isLoading, user, signIn, signOut } = useLogtoAuth();
  if (isLoading) return <Spinner />;
  return isAuthenticated ? (
    <button onClick={() => void signOut()}>Sign out ({user?.email ?? user?.sub})</button>
  ) : (
    <button onClick={() => void signIn()}>Sign in</button>
  );
}

Route guards (TanStack Router)

For redirect-based protection you need auth outside render, in a route's beforeLoad. Lift useLogtoAuth() into the router context and guard there. This is the standard TanStack Router auth pattern; it works because useLogtoAuth() is just state.

src/main.tsx
import { useEffect } from "react";
import { ConvexReactClient } from "convex/react";
import { ConvexLogtoSessionProvider, useLogtoAuth } from "convex-logto/react-session";
import { RouterProvider } from "@tanstack/react-router";
import { router } from "./router";
import { api } from "../convex/_generated/api";

const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL, {
  initialAuthTokenReuse: true,
});

// Lives inside the provider so useLogtoAuth() has its context.
function InnerApp() {
  const auth = useLogtoAuth();

  // Re-run every route's beforeLoad when the authn outcome flips (loading to
  // settled, sign in or out). Key on the primitive booleans so it fires on those
  // changes only, not on every token refresh or profile (`user`) update.
  useEffect(() => {
    void router.invalidate();
  }, [auth.isLoading, auth.isAuthenticated]);

  return <RouterProvider router={router} context={{ auth }} />;
}

root.render(
  <ConvexLogtoSessionProvider
    client={convex}
    sessionApi={api.auth}
    navigate={(to) => void router.navigate({ to, replace: true })}
  >
    <InnerApp />
  </ConvexLogtoSessionProvider>,
);
src/router.tsx
import {
  createRootRouteWithContext,
  createRoute,
  createRouter,
  redirect,
  Outlet,
} from "@tanstack/react-router";
import type { useLogtoAuth } from "convex-logto/react-session";

type RouterAuthContext = { auth: ReturnType<typeof useLogtoAuth> };

const rootRoute = createRootRouteWithContext<RouterAuthContext>()({
  component: () => <Outlet />,
});

// Protected layout route, guarded outside render in beforeLoad.
const authedRoute = createRoute({
  getParentRoute: () => rootRoute,
  id: "_authed",
  beforeLoad: ({ context }) => {
    if (context.auth.isLoading) return; // still settling; don't redirect yet
    if (!context.auth.isAuthenticated) throw redirect({ to: "/signin" });
  },
  pendingComponent: () => <p>Checking access…</p>,
  component: () => <Outlet />,
});

export const router = createRouter({
  routeTree: rootRoute.addChildren([signinRoute, authedRoute.addChildren([dashboardRoute])]),
  context: { auth: undefined! }, // <RouterProvider context> injects the real value
  defaultPendingMs: 0, // no delay before pendingComponent while auth settles
});

A few things here are load-bearing:

  • Check isLoading first. isAuthenticated is false while auth is still settling, so without the if (context.auth.isLoading) return you'd redirect a signed-in user on every reload, and bounce a just-returned user mid sign-in callback. The provider keeps isLoading true across that whole validation window, so the guard waits instead of redirecting.
  • Keep the router.invalidate() effect. It re-runs the guard when auth resolves. Depend on [auth.isLoading, auth.isAuthenticated].
  • Give the route a pendingComponent (with defaultPendingMs: 0) so the loading window shows it instead of a flash of protected content.
  • Pass navigate to the provider so post-sign-in is a soft router navigation, and make it replace-style so the callback URL leaves history.
  • A guard decides what to render, not who may read. The Convex function still checks ctx.auth.getUserIdentity() itself. In session mode, assertSubjectHasActiveSession(ctx, components.logto) also makes it fail the moment the session is revoked.

On this page