convex-logto

Session mode

Keep the Logto refresh token out of the browser. A Convex component holds it, rotates application session tokens, and revokes sessions reactively.

Session mode makes your Convex deployment the OAuth client instead of the browser. It is the recommended mode and what the Quick start sets up. A Convex component holds the Logto refresh token server-side; the browser holds only two things:

  • a short-lived ID token (the bearer Convex validates; minutes of exposure, not weeks), and
  • a rotating session token issued in generations. The server stores only hashes, and accepts the current generation and a bounded set of recently superseded generations during the reuse window. Presentation after that window triggers reuse containment.

Compared to bridge mode (Logto's SPA SDK in the browser, refresh token in localStorage):

Bridge modeSession mode
Logto app typeSingle-page appTraditional web
Refresh token livesbrowser localStorageConvex component (never leaves)
XSS stealsa ~2-week refresh tokena ~1-hour ID token plus a rotating application credential; the component contains late reuse on presentation
Sign-out elsewhere / user suspendedat token expirypushed live (reactive revocation)
Frontend dependencies@logto/reactnone beyond convex + react
Logto config in the bundleendpoint + app idnothing

These are relative mode trade-offs, not an XSS boundary. Apply the SPA security baseline to either mode; it covers CSP, Trusted Types, third-party scripts, and scope minimization without repeating this token model.

The default localStorage transport works on any static host/CDN, no cookie domain or frontend server required. Apps with a same-site server endpoint can move the rotating session token into an HttpOnly cookie; see the cookie transport section below.

Setup

The Quick start walks through it step by step. The pieces:

  1. A Traditional web app in Logto, with http://localhost:5173/callback as a Redirect URI and http://localhost:5173 as a Post sign-out redirect URI. The deployment holds its App Secret. The tenant's OIDC signing key must be RSA, as in bridge mode; Convex rejects Logto's default ES384.
  2. app.use(logto) in convex/convex.config.ts, importing logto from convex-logto/convex.config.
  3. LOGTO_ENDPOINT, LOGTO_APP_ID, and LOGTO_CLIENT_SECRET set with npx convex env set. Every Logto value lives server-side; nothing reaches the browser. The endpoint policy is HTTPS (loopback HTTP works for local development). For an existing HTTP-only, non-loopback self-hosted deployment, pass { allowInsecureHttp: true } to both logtoAuthConfig and logtoSessionApi; prefer terminating TLS instead.
  4. convex/auth.config.ts with the same one line as bridge mode, logtoAuthConfig(). Session mode validates the same OIDC ID token, just issued to the Traditional web app.
  5. convex/auth.ts re-exporting the eleven functions from logtoSessionApi(components.logto) with their exact names: signIn, callback, refresh, signOut, signOutEverywhere, listSessions, renameSession, revokeSession, exchangeToken, fetchUserInfo, sessionValid. The provider looks them up on the module you pass it. A missing one disables that feature with a named error rather than failing the build, so a rolling upgrade is safe.
  6. <ConvexLogtoSessionProvider client={convex} sessionApi={api.auth}> from convex-logto/react-session, with { initialAuthTokenReuse: true } on the ConvexReactClient. Without it Convex refetches a token the moment it confirms the cached one, which in session mode means a Logto refresh grant and a session-token rotation on every page load; see one token round trip per page load.

That's the whole frontend. No @logto/react, no callback component. The provider finishes the exchange on /callback and replace-navigates back into the app by itself. useLogtoAuth() from convex-logto/react-session has the five bridge-mode fields plus the session-management ones:

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

const {
  isAuthenticated,
  isLoading,
  user,
  signIn,
  signOut,
  signOutEverywhere,
  listSessions,
  renameSession,
  revokeSession,
} = useLogtoAuth();
// signIn({ returnTo: "/dashboard" }) takes a same-origin path and lands there
// after sign-in. signOut() clears tabs sharing this localStorage transport and
// ends this browser's Logto SSO session.

Sign out everywhere

signOutEverywhere() logically revokes every existing component Session for the current subject, removes the physical rows in bounded batches, then completes a federated sign-out for the calling browser or native device:

await signOutEverywhere({
  postLogoutRedirectUri: window.location.origin,
});

The component derives the subject only from the caller's presented live session token. It accepts the current generation and a bounded set of recently superseded generations during the configured reuse window. It treats a superseded token outside that window as reuse. It contains only that token's own Session and rejects the operation, leaving the subject's other Sessions intact. There is no client-supplied subject and no read-then-delete race.

The component commits subject-wide logical revocation first, so sessionValid rejects every affected Session at once. It then deletes physical rows in fixed batches. If cleanup exceeds one action's work budget, the marker remains effective and retrying continues cleanup. The returned count is the number of physical rows a completed cleanup removed, not the point when revocation took effect. The client clears local credentials first; other live devices receive sessionValid: false through the existing reactive subscription. If the subject-wide server action fails, the promise rejects so the app can report that the everywhere operation was incomplete, even though this device has already cleared its local credentials.

The caller also follows Logto's end-session URL, ending that device's browser SSO session. The URL carries no ID token, on purpose; Logto's client ID and registered post-logout redirect are enough, as they are for ordinary signOut(). This is still an RP-level boundary. One device cannot erase the Logto OP cookies stored in other devices' browsers. A device that retains a live Logto cookie can start a new sign-in, and Logto may authenticate it without another credential prompt.

Deleting a component row also deletes its server-held Logto refresh token, so that credential becomes unreachable by the app. Logto can reuse one grant for multiple application Sessions under the same SSO session and client, so neither single-session nor subject-wide local cleanup issues per-row RFC 7009 calls that could invalidate still-live siblings. Unreachable grant state expires at its configured Logto TTL.

If an older convex/auth.ts has not re-exported signOutEverywhere, the provider rejects with an upgrade message instead of a cryptic missing-function response. Re-export the eleven-function set shown above and deploy the Convex functions.

Where am I signed in

listSessions() returns the caller's own sessions so a settings screen can show them and revoke one:

const { listSessions, renameSession, revokeSession } = useLogtoAuth();

const { sessions, truncated } = await listSessions();
// [{ sessionId, current, createdAt, lastRefreshedAt, label?, client?, deviceBound }]

await renameSession(sessionId, "Work laptop"); // pass undefined to clear it
await revokeSession(sessionId); // that device drops on its next revocation tick

All three authenticate the same way signOutEverywhere does. The subject comes only from the caller's presented live session token, so a sessionId belonging to another user resolves to session_not_found rather than revealing that it exists. A session already killed by a revocation watermark is invisible to the list and untouchable by the mutations, even while its row waits for a bounded cleanup batch.

listSessions() is a snapshot, not a reactive query. The credential it authenticates with rotates roughly every ID-token lifetime, and a subscription keyed on a rotating credential would resubscribe on every rotation. Call it again after a rename or revoke. It returns at most 16 live sessions, newest first, with truncated: true when more remain. The scan skips rows killed by a watermark without consuming a slot, so a burst of revoked-but-not-yet-deleted sessions can never hide the live device behind them. The scan is bounded, and a page that stops at that bound also reports truncated.

revokeSession() on the current session revokes the server side but leaves this client's local credentials in place; call signOut() when you mean "sign this device out". The device proof requirement applies to the caller's session, not the target. Requiring the target's key would make it impossible to revoke a lost device, which is the main reason this exists.

renameSession() and revokeSession() resolve with no value, or reject with the terminal session_not_found error. The component refuses an id belonging to another subject, or one a watermark already killed, without confirming that it exists. Like signOutEverywhere(), revocation is an RP-level boundary. It deletes the session and its server-held refresh token, but cannot erase the Logto OP cookie in that device's browser, so a device holding one can start a new sign-in. For a lost device, revoke it in Logto (or suspend the user) as well.

Labels and the client descriptor

label is user-chosen and set through renameSession. The component normalizes it on write (collapses whitespace, strips control characters and bidi overrides so one entry cannot impersonate another) and rejects it past 64 code points rather than truncating it.

client is a coarse, self-reported description the app supplies at sign-in so the user recognises their own devices:

<ConvexLogtoSessionProvider
  client={convex}
  sessionApi={api.auth}
  clientDescriptor={{ platform: "web", os: "macOS", browser: "Firefox" }}
>

The library never reads a User-Agent or an IP address. It stores what you pass, trimmed and truncated to 32 code points per field. It is not authenticated, so treat it as display text and never make a security decision with it. deviceBound on each summary, by contrast, is server-derived and does say whether that session needs a device proof to refresh or sign out.

Device binding (optional)

Set deviceBinding to opt into proof of possession for the default session token transport:

<ConvexLogtoSessionProvider
  client={convex}
  sessionApi={api.auth}
  deviceBinding
>
  <App />
</ConvexLogtoSessionProvider>

The browser generates an ECDSA P-256 keypair with Web Crypto and persists the CryptoKeyPair in deployment-namespaced IndexedDB. The private key is non-extractable and never reaches Convex; the callback captures only its public JWK when it creates the Session. Every refresh and sign-out operation signs the presented rotating session token, and the component verifies that signature before it claims, rotates, deletes, or records subject-wide revocation. Because the token changes after every successful refresh, a captured proof cannot authorize the next generation (the token-reuse grace still absorbs honest races).

This protects against off-device replay after an attacker copies the JavaScript-visible session token. The token alone is no longer enough on another machine. It does not claim to defeat arbitrary code already executing on the same origin, which can ask the browser-held key to sign while that code is running.

Binding is opt-in on purpose. If IndexedDB is unavailable, setup fails with an error instead of issuing an unbound session. If storage eviction removes the key while a bound session remains, the component rejects the replacement key's next proof as terminal and the client clears the session for a clean sign-in. That occasional re-authentication trade-off is why ordinary sessions remain unbound by default.

Do not combine deviceBinding with cookieTransport on any browser. The cookie transport already prevents off-device replay by taking the token out of JavaScript, while device binding requires JavaScript to sign that exact token; supporting both would require weakening one of those guarantees. The shared compatibility assertion throws the same configuration error whichever option you enable first. On Safari this also avoids the original ITP failure mode, where Safari can evict IndexedDB while the cookie survives.

Re-evaluate this software fallback when Device Bound Session Credentials (DBSC) is available in every major browser. Once it is, prefer it over this key.

The default rotating token is safe to rotate from localStorage, but an XSS bug can still copy it before the next rotation. If your app can serve an auth endpoint on the same site, the cookie transport puts that credential in a __Host-convex-logto-session cookie with fixed attributes:

__Host-convex-logto-session=<encoded-token>; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=16416000

JavaScript never receives the credential, not in a response body and not in storage, and every successful /token rotation rolls the cookie and renews its 190-day maximum age. That sliding lifetime matches the component's idle-session garbage collection, so cookie transport persists across browser restarts just like the default localStorage credential while the server session remains live. The short-lived ID token still has to reach Convex from JavaScript, so this closes the long-lived session-token exfiltration path rather than claiming to make arbitrary XSS harmless.

The exported handler is a standard (Request) => Promise<Response> function with six routes under /api/logto-session by default:

RoutePurpose
sign-inCreate the Logto authorization URL.
callbackExchange the authorization code and set the first cookie.
tokenRotate the cookie and return a fresh ID token.
sign-outDelete the component Session, expire the cookie, and optionally end SSO.
sessionsList, rename, or revoke the caller's own sessions (op selects). Never expires the cookie; revoking another device must not sign this one out.
tokensExchange an organization or resource token, or fetch the Logto profile (op selects).

Every application request has one fixed CSRF policy: POST, the x-convex-logto-csrf: 1 custom header, and an exact match in allowedOrigins. Cross-origin same-site mounts also get credentialed CORS responses; the handler never accepts wildcards. It streams request bodies through a 64 KiB application limit and returns 413 for an oversized body before JSON parsing.

Shared handler

Next.js and TanStack Start can call the existing public Convex actions with ConvexHttpClient:

src/server/logto-cookie.ts
import { ConvexHttpClient } from "convex/browser";
import { createLogtoSessionCookieHandler } from "convex-logto";
import { api } from "../../convex/_generated/api";

const convex = new ConvexHttpClient(process.env.CONVEX_URL!);

export const logtoCookieHandler = createLogtoSessionCookieHandler({
  sessionApi: api.auth,
  action: (reference, args) => convex.action(reference, args),
  allowedOrigins: [process.env.APP_ORIGIN!],
  basePath: "/api/logto",
});

allowedOrigins contains browser origins (https://app.example.com), not URL paths. Keep CONVEX_URL server-only.

Next.js App Router

Per-framework wiring is on the Next.js, TanStack Start, TanStack Router and Vite pages: where the provider goes, what stays a Server Component, and which SSR seeding is safe.

Mount the handler in a catch-all Route Handler:

app/api/logto/[route]/route.ts
import { logtoCookieHandler } from "@/server/logto-cookie";

export const POST = logtoCookieHandler;
export const OPTIONS = logtoCookieHandler;

Then enable the browser half on the existing provider:

<ConvexLogtoSessionProvider
  client={convex}
  sessionApi={api.auth}
  cookieTransport={{ endpoint: "/api/logto" }}
>
  <App />
</ConvexLogtoSessionProvider>

The user-facing callback page remains /callback; after Logto redirects there, the provider POSTs the code to /api/logto/callback.

TanStack Start

TanStack Start server routes also receive a standard Request:

src/routes/api/logto/$.ts
import { createFileRoute } from "@tanstack/react-router";
import { logtoCookieHandler } from "~/server/logto-cookie";

export const Route = createFileRoute("/api/logto/$")({
  server: {
    handlers: {
      POST: ({ request }) => logtoCookieHandler(request),
      OPTIONS: ({ request }) => logtoCookieHandler(request),
    },
  },
});

Use the same provider configuration shown for Next.js.

Convex HTTP action on a custom domain

Convex can host the standard handler itself. Use a custom domain that shares the frontend's site, for example app.example.com and api.example.com. A default *.convex.site endpoint is cross-site to your app, so it cannot host the cookie transport.

convex/http.ts
import { createLogtoSessionCookieHandler } from "convex-logto";
import { httpRouter } from "convex/server";
import { api } from "./_generated/api";
import { httpAction } from "./_generated/server";

const http = httpRouter();
const cookieAction = httpAction(async (ctx, request) => {
  const handler = createLogtoSessionCookieHandler({
    sessionApi: api.auth,
    action: (reference, args) => ctx.runAction(reference, args),
    allowedOrigins: [process.env.APP_ORIGIN!],
    basePath: "/api/logto",
  });
  return handler(request);
});

http.route({ pathPrefix: "/api/logto/", method: "POST", handler: cookieAction });
http.route({
  pathPrefix: "/api/logto/",
  method: "OPTIONS",
  handler: cookieAction,
});
export default http;

Point cookieTransport.endpoint at the absolute custom-domain URL. The adapter uses credentials: "include"; the handler returns a credentialed CORS response only for an exact allowed origin.

Authenticated first paint with initialToken

handler.getInitialToken(request) is the server-only SSR path. It reads the cookie, rotates it, and returns { initialToken, initialSessionId, headers }. Call it at most once per incoming document request and always forward its headers to the page response; the Set-Cookie header carries the next token generation. If your rendering hook cannot control response headers, let the browser use /token instead. The seed is best-effort under concurrent renders. Every failed seed returns empty without changing the cookie. The client refreshes after hydration, and the browser /token route is what clears a dead session.

For example, a TanStack Start server function can seed the root loader:

import { createServerFn } from "@tanstack/react-start";
import { getRequest, setResponseHeader } from "@tanstack/react-start/server";
import { logtoCookieHandler } from "~/server/logto-cookie";

const seedSession = createServerFn().handler(async () => {
  const seed = await logtoCookieHandler.getInitialToken(getRequest());
  // Forward header by header. `setResponseHeaders` takes a `Headers`-like
  // object, so handing it `Object.fromEntries(seed.headers)` does not
  // typecheck.
  for (const [name, value] of seed.headers) setResponseHeader(name, value);
  return {
    initialToken: seed.initialToken,
    initialSessionId: seed.initialSessionId,
  };
});

// In the root route: loader: () => seedSession()
const seed = Route.useLoaderData();
<ConvexLogtoSessionProvider
  client={convex}
  sessionApi={api.auth}
  cookieTransport={{ endpoint: "/api/logto" }}
  initialToken={seed.initialToken}
  initialSessionId={seed.initialSessionId}
>
  <App />
</ConvexLogtoSessionProvider>;

The server and hydration snapshots start authenticated, so Convex can accept the seeded ID token on the first paint without a browser /token round-trip.

Cookie transport and software device binding are alternative defenses against off-device session-token replay, not cumulative settings. Enabling both throws through assertLogtoSessionCookieCompatibility; the low-level handler and browser adapter retain a deviceBinding compatibility flag so non-React mounts fail through that same path. Safari's ITP key-eviction behavior is an additional reason for the exclusion, but the HttpOnly/signing conflict applies everywhere.

Reactive revocation

Every session's liveness is a Convex subscription. The moment a sign-out in another tab, token-theft detection, or a webhook suspension kills a session server-side, Convex pushes false and the app drops to signed-out live, not at token expiry.

For functions whose policy is subject-wide revocation, enforce it server-side too (an ID token stays cryptographically valid until it expires):

convex/me.ts
import { assertSubjectHasActiveSession } from "convex-logto";
import { components } from "./_generated/api";
import { query } from "./_generated/server";

export const sensitive = query({
  handler: async (ctx) => {
    await assertSubjectHasActiveSession(ctx, components.logto);
    // ...
  },
});

assertSubjectHasActiveSession() verifies that the authenticated bearer's subject has at least one active component Session. It does not prove that the component issued this bearer for that particular Session. Use it when subject-wide revocation is the policy boundary. Its transaction-bounded scan can throw the transient session_liveness_scan_incomplete error while a large revocation is still draining; retry rather than treating that as a definitive session_revoked. assertUserHasActiveSession remains as a deprecated compatibility alias.

To wire Logto-side account events into revocation, register the webhook with the component attached. Deleting or suspending a user in Logto Console then kills their sessions within seconds:

convex/http.ts
registerLogtoWebhook(http, internal.logto.sync, { sessions: components.logto });

To propagate an individual Logto session ending (including sign-out from another app), register the verified OIDC back-channel logout endpoint. It stores the ID token's optional sid and revokes every component Session mapped to the Logout Token's sid; a token carrying only sub uses the specification's subject-wide fallback. It records logical revocation before bounded physical cleanup. The same liveness subscription pushes the revocation, so the client needs no changes.

Plan for one consequence. A reactive query gated this way starts throwing when the authenticated subject no longer has any active component Session. Wrap such data in an error boundary (ordinary Convex practice for query errors) so subject-wide revocation renders as a clean sign-out; the example's SessionBoundary is a 15-line version.

Configuration faults never delete sessions

A refresh has three possible failures, and the component keeps them apart. If Logto rejects the grant, because the refresh token was revoked, reused, or the SSO session ended, that is terminal. The component deletes the row and the user signs in again. If the failure describes your deployment instead, the component keeps the session and reports a transient error, so fixing the environment variable is the whole recovery. And if the component cannot tell what Logto did with the grant, the outcome is unknown and gets its own conservative handling, below.

Deployment faults include a wrong LOGTO_CLIENT_SECRET (invalid_client), a LOGTO_ENDPOINT that no longer matches the iss Logto issues, say after moving behind a custom domain or a reverse proxy, and a missing openid scope. Any of these would otherwise destroy every session in the deployment, one refresh at a time.

What makes keeping the session safe is that Logto told us what it did. It either returned a rotated refresh token, which the component persists before failing, or returned none, which means the stored one is still current. Presenting a superseded refresh token again would trip Logto's reuse detection and destroy the whole grant, including the sibling sessions that share it.

The unknown case is a response the component cannot parse as a token response at all: a proxy or WAF interstitial where JSON was expected, or a body too large to read. Whether Logto processed the grant is then unknown, so the component lets the refresh claim expire and that session requires a fresh sign-in. Guessing there is what risks spending a rotated token twice. During sign-in every one of these is terminal instead, including a wrong client secret. The component consumes the sign-in transaction before it contacts Logto, so the attempt is unrepeatable either way and a retry could only report a stale callback, hiding what went wrong. The provider retries once a failure that never reached the deployment at all, since nothing was consumed.

A transient failure is not a sign-out, and the browser keeps retrying it. The session token stays in storage and the provider re-presents it on its own schedule until the refresh succeeds, the session is signed out, or the tab closes: a second, then two, five, ten, then every thirty seconds. This matters because Convex stops asking for a token after one failure. Without the retry, a tab that hit a tunnel hiccup or woke from sleep before its network did would sit signed-out until the user reloaded.

How the token dance works

  1. Sign-in. signIn() asks the component for an authorize URL. The OIDC state and PKCE verifier stay server-side (a one-time, 10-minute transaction); the browser also pins the state to the signing-in tab, so the provider refuses a forged or replayed callback before any exchange.
  2. Exchange. The callback action redeems the code (client secret + PKCE), stores the refresh token in the component, and hands the browser the ID token plus session token #1. With device binding enabled, it also stores the browser's public key; the private key never leaves IndexedDB.
  3. Refresh. When the ID token nears expiry, the browser presents session generation #1 (and, for a bound Session, its signature); the component verifies proof before refreshing against Logto and answers with a fresh ID token plus generation #2. The component retains a bounded set of superseded generation hashes until each generation's reuse-window expiry so concurrent responses do not false-positive.
  4. Reuse containment. If a superseded generation appears after its window, the component abandons the Session and its associated server-held refresh token becomes unreachable; that client must reauthenticate without invalidating a still-live sibling that shares Logto grant state.
  5. Sign-out. Clears local state first (other tabs sharing localStorage follow via the storage event), removes the component Session, then ends the current browser's SSO session unless you disable federated sign-out. An app whose sign-in route calls signIn() on mount reaches it in that window, so signIn() waits for the sign-out and starts nothing once the page is leaving for Logto. Otherwise the authorize navigation would cancel the end-session one and Logto's SSO cookie would sign the user straight back in.

The client single-flights concurrent refreshes at three layers: per tab (in-flight merge), per browser (Web Locks), per session (a server-side claim). A concurrent replay of the same Logto refresh token at its rotation boundary would destroy the grant. Server claims are ownership-fenced. If a claim exceeds its safety timeout, the remote outcome is unknown, so the client abandons the local Session instead of spending the stored refresh token again. The component returns credentials only after the matching claim commits; concurrent deletion or ownership loss rejects the response and leaves uncommitted remote credentials unreachable.

Provider props

PropDefault
clientnoneYour ConvexReactClient.
sessionApinoneThe module re-exporting logtoSessionApi(...), e.g. api.auth.
callbackPath"/callback"Route that finishes the redirect (exact match; must match the registered Redirect URI's path).
afterSignIn"/"Where to land after sign-in; signIn({ returnTo }) overrides.
navigatehard replaceYour router's navigate for soft post-sign-in navigation. Prefer a replace-style one so the callback URL leaves history.
tokenStorage"session"Where the ID token persists: "session" (per-tab; a fresh token restores without refresh only while its paired component-session marker remains), "memory" (strictest), "local". The provider clears orphan ID tokens.
deviceBindingfalseBind refresh and revocation operations to a non-extractable IndexedDB-held ECDSA key. Cannot be combined with cookieTransport.
cookieTransportnone{ endpoint?, fetch?, deviceBinding? }. Moves the rotating session token into a persistent same-site HttpOnly cookie whose 190-day lifetime renews on every rotation. The reserved deviceBinding flag only activates the shared incompatibility assertion.
clientDescriptornone{ platform?, os?, browser? }, the self-reported device description listSessions() shows. Display text only; never authenticated.
initialToken / initialSessionIdnonePaired values from handler.getInitialToken(request) for authenticated SSR/hydration.
reactiveRevocationtrueSubscribe to session liveness and drop auth live on revocation.
onAuthErrornoneSign-in initiation, callback, and sign-out failures, plus opted-in device-key storage failures. The provider reports a failure before the promise rejects, so void signIn() remains observable.
onAuthEventnoneOpt-in phase timings for the auth bootstrap; see Auth phase events. Absent means nothing is measured.

logtoSessionApi(component, opts?) accepts scopes (server-set; the browser can't request its own), reuseWindowMs, env overrides, and two options that belong to the token exchange below: resources (API resource indicators, which Logto requires before sign-in) and exposeAccessTokens.

Organization and API-resource tokens

Organization membership and roles need no token here. Request ORGANIZATIONS_SCOPE and ORGANIZATION_ROLES_SCOPE in logtoSessionApi({ scopes }), and Logto puts the matching claims in the ID token. The two are independent scopes, so ask for both. user.organizations and user.organization_roles are then in every authenticated request, and assertOrganizationRole reads them server-side for free, as a snapshot from when Logto issued the token, which is the one thing to know before authorizing on them. Without the scopes the claims are absent, and absent authorizes nothing.

What does need a token is a fine-grained organization permission, or a non-Convex API you registered with Logto. The component mints those from the Session's Logto refresh token and returns what they authorize:

const { getOrganizationTokenClaims, fetchUserInfo } = useLogtoAuth();

const { scopes } = await getOrganizationTokenClaims("org-id");

Set exposeAccessTokens: true on logtoSessionApi() if a caller needs the token string itself in the browser. getOrganizationToken(id) and getAccessToken(resource) then return it, and it becomes one more thing XSS can steal. The refresh token has no such option in either mode.

Two constraints worth knowing before you design around it:

  • A resource must be named before sign-in. Logto answers invalid_target for a resource the grant never mentioned, so resources is fixed at sign-in and widening it means signing the user in again.
  • The exchange shares the refresh claim, because it spends the same Logto refresh token, so it can answer the transient refresh_in_flight while a refresh is in flight. The component caches minted tokens per session, audience and scope set, so a permission check on render is not a grant per render.
Bridge mode already has all of this. @logto/react's useLogto() is in your bundle, so call getOrganizationToken on it.

Choosing a mode

Session mode is the default recommendation. It keeps long-lived credentials out of the browser, signs revoked sessions out at once, and ships a bundle with no auth SDK. Stay on bridge mode if you want zero server-side state or you're already running it; it remains supported. Both modes present the same ID token to Convex, so ctx.auth.getUserIdentity() and the webhook user sync work the same, and migrating is a config change (new Logto app + provider swap), not a data migration.

See the runnable vite-react-session example.

On this page