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.