convex-logto

Quick start

Wire Logto auth into a Convex + Vite React app end to end, in six steps. Session mode, so the browser never holds a refresh token.

The Vite + React happy path, top to bottom, in session mode. Copy it as is and your Convex functions will see a signed-in user. On TanStack Router / Start, Next.js, or Expo? Steps 1 to 3 are the same; the provider and callback differ; see Integrations. If you want Logto's SDK in the browser instead, follow Bridge mode.

Before you start, rotate the Logto signing key to RSA. Convex only accepts ID tokens signed with RS256 (or EdDSA). Logto signs with ES384 by default, which Convex rejects without an error. Sign-in looks like it works, but ctx.auth.getUserIdentity() returns null. In the Logto Console open Tenant settings → OIDC configs → Rotate private keys and choose RSA. One-time, per tenant; Logto keeps the old key during a transition, so existing sessions stay signed in.

Install

pnpm add convex-logto

convex and react are peers you already have. There is no Logto SDK to install; your Convex deployment is the OAuth client. For React Native, see Expo.

1. Create a Logto app

In Logto Console → ApplicationsCreate applicationTraditional web. Pick this type even though your frontend is a SPA. The Convex deployment holds the app secret, and only a Traditional web app has one. A Single page app has no secret, and a Third-party app withholds the profile / email scopes this package requests. You can't change the type after creation.

Note the endpoint (e.g. https://auth.example.com), the App ID, and the App Secret, and add two URLs on the app (for each environment):

  • Redirect URIshttp://localhost:5173/callback (and your prod callback)
  • Post sign-out redirect URIshttp://localhost:5173 (your app's origin, and your prod origin)

signIn() returns to the redirect URI and signOut() to the post-sign-out URI, so add both.

2. Set the config

Everything goes on the Convex deployment. The frontend has no Logto env vars:

npx convex env set LOGTO_ENDPOINT      https://auth.example.com
npx convex env set LOGTO_APP_ID        your-app-id
npx convex env set LOGTO_CLIENT_SECRET your-app-secret

The app secret stays on the deployment and never reaches the browser.

The endpoint may include a reverse-proxy path prefix, but it must be the Logto base URL (not the trailing /oidc issuer URL) and must not contain credentials, a query, or a fragment. The library requires HTTPS; loopback HTTP works for local development. If an existing self-hosted deployment can only be reached over non-loopback HTTP, add allowInsecureHttp: true to both logtoAuthConfig and logtoSessionApi. That option exists for compatibility. Terminate TLS instead when you can.

3. Wire Convex

Three files. The component holds the Logto refresh tokens, auth.config.ts tells Convex to validate Logto's ID token, and auth.ts exposes the functions the provider calls.

convex/convex.config.ts
import { defineApp } from "convex/server";
import logto from "convex-logto/convex.config";

const app = defineApp();
app.use(logto);
export default app;
convex/auth.config.ts
import { logtoAuthConfig } from "convex-logto";
export default { providers: [logtoAuthConfig()] };
convex/auth.ts
import { logtoSessionApi } from "convex-logto";
import { components } from "./_generated/api";

export const {
  signIn,
  callback,
  refresh,
  signOut,
  signOutEverywhere,
  listSessions,
  renameSession,
  revokeSession,
  exchangeToken,
  fetchUserInfo,
  sessionValid,
} = logtoSessionApi(components.logto);

Re-export all eleven with these exact names. The provider looks them up on the module you pass it. Run npx convex dev once after adding convex.config.ts; that regenerates _generated/api with components.logto on it.

4. Wrap your app

src/main.tsx
import { ConvexReactClient } from "convex/react";
import { ConvexLogtoSessionProvider } from "convex-logto/react-session";
import { api } from "../convex/_generated/api";

const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL, {
  // Without this, Convex confirms the cached token and then refetches a fresh
  // one at once. That is a Logto refresh grant and a session-token rotation
  // on every page load. Convex still marks the option experimental.
  initialAuthTokenReuse: true,
});

root.render(
  <ConvexLogtoSessionProvider
    client={convex}
    sessionApi={api.auth}
    onAuthError={(error) => console.error("auth error", error)}
  >
    <App />
  </ConvexLogtoSessionProvider>,
);

sessionApi is the module from step 3. onAuthError is where a failed sign-in or sign-out surfaces (Logto unreachable, a stale callback, blocked storage); wire it to a toast. One token round trip per page load explains initialAuthTokenReuse.

Using a router or SSR framework? Pass navigate so the post-sign-in landing is a soft, replace-style navigation, and see Integrations for where the provider lives in TanStack Router / Start, Next.js, and Expo.

5. Add a callback route

signIn() lands on /callback. The provider owns that path. It POSTs the code to your callback action, stores the credentials, and replace-navigates to afterSignIn (default /), so whatever you render there is only ever a flash. In a plain Vite app with no router:

src/App.tsx
if (window.location.pathname === "/callback") return <p>Finishing sign in…</p>;

6. Sign in, and read the user

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

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

The void signIn() event-handler form is fine. The provider reports a failure to onAuthError and the console before the promise rejects, so nothing fails silently.

signOut() clears this browser, deletes the server session, then ends the Logto session and returns the user to your origin (the Post sign-out redirect URI from step 1). Pass signOut({ postLogoutRedirectUri: "https://app.com/bye" }) to land somewhere else; register that URI too. Don't follow it with a hard navigation of your own (location.href = ...). signOut() resolves once it has told the browser to leave for Logto, and a second full-page navigation supersedes the request that ends the Logto session.

In any Convex function, the Logto identity is already there:

convex/me.ts
import { query } from "./_generated/server";

export const me = query({
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) return null;
    // identity.subject = Logto user id, plus email/name/etc. from the ID token
    return { id: identity.subject, email: identity.email, name: identity.name };
  },
});

That is the whole auth setup. Many apps need nothing more. From here:

  • Auth in your app gates and routes on auth state.
  • Session mode covers what you now have: the device list, signOutEverywhere(), device binding, the HttpOnly cookie transport for apps with a same-site server, and how the component classifies a failed refresh.
  • Webhook sync mirrors users into a table and, with the component attached, revokes a deleted or suspended user's sessions within seconds.

The runnable version is examples/vite-react-session.

On this page