convex-logto

How it works

Why the ID token, OIDC auto-discovery, refresh tokens, and a couple of platform notes.

Why the ID token (and why there's no JWT config)

Convex validates an OIDC ID token. Logto's access tokens are typed at+jwt, which Convex does not accept (convex#75), so this package returns the ID token.

Because it goes through Convex's OIDC provider (not Custom JWT), Convex reads the issuer's discovery document and JWKS itself, so you never set an algorithm or a JWKS URL.

There is one catch. Convex's OIDC verifier accepts only RS256 and EdDSA signatures, but Logto signs with ES384 by default, and Convex rejects a mismatched signature without reporting it. Sign-in completes, yet getUserIdentity() returns null. The fix is a one-time, tenant-level key rotation. In the Logto Console, open Tenant settings → OIDC configs, click Rotate private keys, and choose RSA as the signing algorithm. Logto keeps the old key during a transition, so existing sessions stay signed in. After rotating, the discovery document advertises RS256 and Convex accepts the token.

Refresh via offline_access

Sessions refresh via Logto's refresh token, which is why both modes request the offline_access scope by default. In session mode the Convex component holds that refresh token and spends it; in bridge mode @logto/react does, from localStorage.

One token round trip per page load, unless you opt out

Convex's client fetches an auth token twice on every page load. It sends the one your app already has, waits for the server to confirm it, and then, by default, throws it away and asks for a fresh one. The second fetch is forceRefreshToken, which this library is obliged to honour. It means "the token you last gave me will not do."

That costs a Logto token-endpoint round trip on every page load, in both modes. In bridge mode the Logto SDK spends a refresh grant; in session mode your deployment does, and rotates the session token with it. None of it is needed when the token in hand is fresh and the server just said so.

Convex has an option for this. Turn it on where you construct the client:

const convex = new ConvexReactClient(CONVEX_URL, {
  initialAuthTokenReuse: true,
});

Now the client keeps the confirmed token and schedules a refresh before it expires, which is what makes a warm page load reach an authenticated render without touching the network at all. Every example in this repository sets it.

Convex still marks it experimental (convex@1.45) and it may change; that is the only reason it is not the default here.

What you give up

That per-page-load round trip was doing one useful thing by accident. It asked Logto whether the grant was still alive. Reusing the cached token skips the question, so a session revoked at Logto, by an admin ending it, a password change, or another device signing out everywhere, goes unnoticed until the token expires. With Logto's default one-hour ID token, that is a window of up to an hour of page loads.

In session mode the provider closes that window with something better. It subscribes to sessionValid, so a revocation lands on the open tab live, without a reload and without asking Logto. Turn the option on.

In bridge mode there is no such subscription, and the window is real. It is the same window an already-open tab has always had, but it now covers page loads too. The token is cached for its lifetime either way, and closing the browser was never what made a revocation take effect. If your threat model needs revocation to bite faster than the ID token's lifetime, either leave the option off and pay the round trip knowingly, or shorten the ID token's TTL in Logto, or use session mode.

Server-side rendering

Both providers are safe to render on the server, so you need no stub or mount gate. ConvexLogtoSessionProvider renders one fixed restoring snapshot on the server and touches no browser API until it mounts. ConvexLogtoProvider with static config touches nothing in window during render either; with configQuery it renders fallback (default null) until the query resolves, on the server and on the first client paint alike, and mounts children once.

In TanStack Start, render the same single provider you'd use in a SPA. In the Next.js App Router the component that imports it must still be "use client" (its hooks and the sign-in / sign-out actions use window), but that's the only boundary.

ESM-only React entries

convex-logto/react-session and convex-logto/react are ESM-only (so is the @logto/react peer of the latter). This is a non-issue for Vite, Next.js, and TanStack (all ESM); it would only bite a CommonJS (require) consumer. The root convex-logto (server) entry stays dual ESM + CJS.

On this page