convex-logto

API reference

Every export from convex-logto and its web, native, and session-mode entries, with signatures.

Exports

ExportFromPurpose
logtoAuthConfig(opts?)convex-logtoProvider entry for auth.config.ts. Reads LOGTO_ENDPOINT / LOGTO_APP_ID.
logtoConfigQuery(opts?)convex-logtoPublic query serving { endpoint, appId, allowInsecureHttp? } to a bridge-mode frontend that resolves its config at runtime.
logtoSync<DataModel>(handlers)convex-logtoReturns { sync }, an internal mutation mapping user events to your tables.
registerLogtoWebhook(http, sync, opts?)convex-logtoRegisters the verified webhook route. Reads LOGTO_WEBHOOK_SIGNING_KEY.
verifyLogtoSignature(key, body, sig)convex-logtoLow-level signature check, for custom routing.
registerLogtoBackchannelLogout(http, opts)convex-logtoRegisters the verified OIDC back-channel logout route for session mode.
createLogtoBackchannelLogoutHandler(opts)convex-logtoBuilds the same Convex HTTP action without registering a path.
verifyLogtoLogoutToken(token, opts?)convex-logtoLow-level Web Crypto/JWKS validation for an OIDC Logout Token.
logtoSessionApi(component, opts?)convex-logtoSession mode: builds the eleven public auth functions backed by the session component.
assertSubjectHasActiveSession(ctx, component)convex-logtoSession mode: require at least one active component Session for the authenticated subject; does not bind this bearer to one Session.
assertUserHasActiveSession(ctx, component)convex-logtoDeprecated compatibility alias for assertSubjectHasActiveSession.
createLogtoSessionCookieHandler(opts)convex-logtoSix-route standard-fetch handler for the optional same-site HttpOnly cookie transport.
createLogtoSessionCookieTransport(api, opts?)convex-logtoFramework-free browser adapter used by the session provider's cookieTransport prop.
assertLogtoSessionCookieCompatibility(opts)convex-logtoLoud Safari/device-binding compatibility guard.
readLogtoIdTokenCookie(source)convex-logtoReads the opt-in SSR ID token cookie from a Request, a Cookie header, or a Next-style store.
LOGTO_SESSION_COOKIE_*, LOGTO_ID_TOKEN_COOKIE_NAME, LOGTO_SESSION_CSRF_*convex-logtoFixed cookie names/base path and CSRF header/value constants.
assertOrganizationMember / assertOrganizationRoleconvex-logtoOrganization authorization from the ID token Convex already validated.
logtoOrganizations / logtoOrganizationRolesconvex-logtoThe same claims, read rather than asserted.
ConvexLogtoProviderconvex-logto/reactBridge mode's provider, on @logto/react. Static config or backend configQuery.
useLogtoAuth()convex-logto/react{ isAuthenticated, isLoading, user, signIn, signOut }.
defaultconvex-logto/convex.configThe session component, for app.use(logto) in convex/convex.config.ts.
ConvexLogtoSessionProviderconvex-logto/react-sessionSession mode's provider. No Logto SDK; talks to your logtoSessionApi functions.
useLogtoAuth()convex-logto/react-sessionSession auth plus signOutEverywhere({ postLogoutRedirectUri? }), listSessions() / renameSession() / revokeSession(), and getIdToken() / getOrganizationTokenClaims() / getAccessTokenClaims() / fetchUserInfo().
SessionSignOutErrorconvex-logto/react-session, convex-logto/native-sessionThe error signOut() rejects with when local credential cleanup fails twice; serverSessionStatus says whether the server session survived.
LogtoUserClaimsconvex-logtoThe type of user in all four entries. Standard claims are named; everything else comes through an index signature. Import it to type a helper that takes one.
ConvexLogtoProviderconvex-logto/nativeReact Native / Expo provider (on @logto/rn). Same config / configQuery model; no callback route.
useLogtoAuth()convex-logto/nativeNative { isAuthenticated, isLoading, user, signIn, signOut }; signIn({ redirectUri? }) / signOut({ postLogoutRedirectUri? }).
ConvexLogtoSessionProviderconvex-logto/native-sessionReact Native / Expo session provider using SecureStore + the system browser.
useLogtoAuth()convex-logto/native-sessionNative session { isAuthenticated, isLoading, user, signIn, completeSignIn, signOut, signOutEverywhere, listSessions, renameSession, revokeSession } plus the same token-exchange methods; no device-binding option.

Backend

logtoAuthConfig(opts?)

Provider entry for convex/auth.config.ts. Reads LOGTO_ENDPOINT and LOGTO_APP_ID from the deployment env. Endpoints must be absolute HTTPS URLs without credentials, a query, or a fragment. Loopback HTTP works for local development; a non-loopback self-hosted HTTP deployment must pass allowInsecureHttp: true and accept the transport risk that comes with it.

convex/auth.config.ts
import { logtoAuthConfig } from "convex-logto";
export default { providers: [logtoAuthConfig()] };

logtoConfigQuery(opts?)

A public Convex query that serves { endpoint, appId, allowInsecureHttp? } to a bridge-mode frontend that resolves its Logto config at runtime, so the bundle carries no Logto values. Static config on the provider is the bridge-mode default; session mode needs neither, since every Logto value stays on the deployment.

convex/logto.ts
import { logtoConfigQuery } from "convex-logto";
export const config = logtoConfigQuery();

It applies the same endpoint policy as logtoAuthConfig. If you opt an HTTP-only self-hosted deployment in with allowInsecureHttp: true, pass the same option here so runtime-loaded browser/native config carries that policy.

logtoSync<DataModel>(handlers)

Returns { sync }, an internal mutation that maps Logto user events (User.Created, User.Data.Updated, User.SuspensionStatus.Updated, User.Deleted) to your tables. See Webhook sync.

registerLogtoWebhook(http, sync, opts?)

Registers the verified webhook route (serves POST /logto/webhook). Reads LOGTO_WEBHOOK_SIGNING_KEY. Enforces a freshness window on createdAt and a 1 MB body cap. With sessions: components.logto (session mode), the route deduplicates deliveries by raw-body hash, and user deletion or suspension revokes the user's sessions.

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

verifyLogtoSignature(key, body, sig)

Low-level signature check (HMAC-SHA256 via Web Crypto), for when you route the webhook yourself instead of using registerLogtoWebhook.

registerLogtoBackchannelLogout(http, opts)

Registers POST /logto/backchannel-logout for OIDC back-channel logout. Pass sessions: components.logto; the route reads LOGTO_ENDPOINT / LOGTO_APP_ID unless you pass endpoint / appId overrides. A custom path changes the registered URI.

The handler verifies RS256 / PS256 Logout Tokens against Logto's cached JWKS, enforces issuer, audience, time, event, subject/session, jti, and no-nonce rules, then revokes by sid or falls back to all sessions for sub. It deduplicates verified jti values for 24 hours. Success (including no matching session or a replay) is 200; invalid requests are 400, and bodies over 1 MB are 413. Every response is Cache-Control: no-store.

convex/http.ts
registerLogtoBackchannelLogout(http, { sessions: components.logto });

createLogtoBackchannelLogoutHandler(opts)

Returns the same Convex HTTP action for custom router composition. It still enforces POST and form encoding. Options are sessions, optional endpoint / appId, and no path (the caller owns routing).

verifyLogtoLogoutToken(token, opts?)

The low-level verifier the registered handler uses. Returns { issuer, subject?, sid?, jti } after Web Crypto signature and OIDC claim validation; rejects invalid, encrypted, unsigned, or symmetrically signed tokens. It does not mutate or deduplicate sessions.

logtoSessionApi(component, opts?)

Session mode's server half. Pass components.logto (after app.use(logto)) and re-export the eleven functions it returns. Reads LOGTO_ENDPOINT, LOGTO_APP_ID, and LOGTO_CLIENT_SECRET.

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);

Options: scopes (server-configured; the browser can't request its own), reuseWindowMs (default 10s), endpoint / appId / clientSecret env overrides, and the same allowInsecureHttp compatibility option described above.

resources is the input to the token exchange. Logto refuses to issue a token for a resource the grant never named, so the set is fixed before sign-in and every indicator must be registered. An unregistered one breaks sign-in outright. The scopes you want from those resources go in scopes; naming the resource alone yields a token with none. exposeAccessTokens lets the token string reach the browser at all, and is off by default.

signOutEverywhere({ sessionToken, postLogoutRedirectUri? }) derives the subject inside the component and records subject-wide logical revocation in one transaction. The action removes physical rows in bounded batches and returns { count, endSessionUrl } after cleanup completes. The subject is never a public argument. The component accepts the current generation and a bounded set of recent ones during their reuse windows; presenting a known superseded generation later contains only that stale Session and raises the normal terminal reuse error. If an action exceeds its bounded cleanup budget, the marker remains effective and retrying continues deletion. count is the number of physical rows a completed cleanup removed, not the point when revocation became effective.

listSessions({ sessionToken, deviceProof? }) returns { sessions, truncated }: at most 16 of the caller's own live sessions, newest first, each { sessionId, current, createdAt, lastRefreshedAt, label?, client?, deviceBound }. The scan skips sessions killed by a revocation watermark without consuming a page slot (it is bounded and walks past them), so pending cleanup can never hide a live device; truncated reports both "more sessions exist" and "the scan stopped at its bound". renameSession({ sessionToken, targetSessionId, label? }) and revokeSession({ sessionToken, targetSessionId }) succeed or raise terminal session_not_found. They never report failure by returning; the useLogtoAuth wrappers resolve to void. All three derive the subject from the presented token as signOutEverywhere does, so a targetSessionId owned by another subject, or already killed by a revocation watermark, raises the normal terminal session_not_found instead of revealing that it exists. The component normalizes label (collapses whitespace, strips control characters and bidi overrides) and rejects it past 64 code points rather than truncating; it truncates the advisory client descriptor to 32 code points per field.

The generated callback, refresh, signOut, and signOutEverywhere actions also accept optional device-binding fields that ConvexLogtoSessionProvider sends (devicePublicKey and deviceProof). A bound Session verifies proof before refresh or revocation state changes. Existing callers and unbound Sessions do not send them and retain the unbound behavior.

createLogtoSessionCookieHandler(opts)

Builds the session mode cookie transport as a standard (Request) => Promise<Response> handler. Options are:

  • sessionApi: the same api.auth module you pass to the provider;
  • action(reference, args): calls the referenced public Convex action;
  • allowedOrigins: non-empty exact browser-origin allowlist;
  • basePath: default /api/logto-session;
  • deviceBinding: mirrors the device-binding flag so non-React mounts reject the incompatible combination through the shared loud error;
  • idTokenCookie: off by default. Also writes the ID token to __Host-convex-logto-id-token, so a server that cannot rotate the session cookie during render can still read an identity. See readLogtoIdTokenCookie.

The six final path segments are sign-in, callback, token, sign-out, sessions, and tokens. Calls require POST, x-convex-logto-csrf: 1, and an allowed Origin; approved preflights receive credentialed CORS headers. The handler streams bodies through a 64 KiB limit and returns 413 for an oversized request. signOutEverywhere uses an internal selector on the existing sign-out route rather than a route of its own; sessions multiplexes list / rename / revoke, and tokens multiplexes exchange / userinfo, on an op field the same way. The sessions route never clears the cookie; revoking another device must not sign this one out. The cookie is fixed to __Host-convex-logto-session=<encoded-token>; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=16416000 and rolls on every successful token rotation. Each roll renews the 190-day maximum age, matching the component's idle-session garbage collection window.

The returned handler also has getInitialToken(request). It returns { initialToken, initialSessionId, headers }; call it at most once per incoming document request and always forward headers to the SSR response so the rotated Set-Cookie is not lost, then pass the two initial values to ConvexLogtoSessionProvider. Concurrent requests are best-effort. Every failed seed returns empty without changing the cookie, and the browser /token route is what clears a dead session.

readLogtoIdTokenCookie(source)

Reads the ID token the route handler stored when idTokenCookie: true, for server-side rendering. source may be a Request, a raw Cookie header string, or a store shaped like Next.js's cookies(), so there is no framework entry point to keep in step, and the same call works in the Next.js App Router, TanStack Start, or a bare handler.

const token = readLogtoIdTokenCookie(await cookies());
const preloaded = await preloadQuery(api.me.me, {}, token ? { token } : {});

Returns null when the cookie is absent, and when the token has expired. The cookie's Max-Age comes from the token's own exp, so a browser stops sending an expired one on its own; the read checks exp as well, because a Cookie header replayed from a cache or a proxy is not behind that guarantee. A token that outlived its cookie would server-render a page as signed in for a bearer Convex refuses.

This mints nothing and rotates nothing, so it costs the session token's rotation-based theft detection nothing. What it does cost is custody. A cookie rides on every same-origin request, reaching access logs and proxies that an Authorization header does not, which is why it is opt-in. A token read here is a bearer Convex validates, not a claim to trust; assertSubjectHasActiveSession inside the function you call enforces revocation, as it does everywhere else.

The handler skips rather than clears an ID token that is already expired, is not a JWT, or is too large for a cookie (over 3 KiB encoded). The previous cookie expires on its own instead of a size problem becoming a sign-out. Every exit that expires the session cookie expires this one with it: sign-out on success and on failure, and a terminal /token refresh. That holds even when idTokenCookie is off, so turning it off never strands a live token. Neither cookie is reachable from JavaScript, so a half-cleared pair is one nothing on the client can finish.

Cookie transport and device binding cannot be combined. HttpOnly makes the session token unavailable to the JavaScript-held signing key, and cookie transport already removes the off-device token-exfiltration path. The compatibility error applies on every browser; Safari ITP key eviction is an additional reason for the same exclusion.

Organization authorization

Logto maps urn:logto:scope:organizations to an organizations claim and urn:logto:scope:organization_roles to an organization_roles claim in the ID token, and Convex passes claims it does not recognise through to ctx.auth.getUserIdentity(). So membership and roles are already inside the request Convex authenticated. No token exchange, no second round trip. Add the scopes (ORGANIZATIONS_SCOPE, ORGANIZATION_ROLES_SCOPE are exported) to the provider's scopes in bridge mode, or to logtoSessionApi({ scopes }) in session mode.

import { assertOrganizationRole } from "convex-logto";

export const deleteInvoice = mutation({
  args: { organizationId: v.string(), id: v.id("invoices") },
  handler: async (ctx, { organizationId, id }) => {
    await assertOrganizationRole(ctx, organizationId, ["admin", "billing"]);
    await ctx.db.delete(id);
  },
});
  • assertOrganizationMember(ctx, organizationId): throws a terminal organization_forbidden unless the caller belongs to it.
  • assertOrganizationRole(ctx, organizationId, roles): one role or a list; any match passes. Matches on the organization and the role, so one organization's viewer cannot authorize another's.
  • logtoOrganizations(ctx) / logtoOrganizationRoles(ctx, organizationId): the same claims, read rather than asserted.
  • parseOrganizationRole(entry): splits Logto's {organizationId}:{roleName} on the first colon, so a role name may contain one.

A missing scope authorizes nothing, not everything. Absent and empty are the same answer, because a deployment that never requested the scope is indistinguishable from a user who belongs to nothing, and only one reading is safe. The failure names the scope so a configuration gap does not read as a denial. The two scopes are independent. Logto advertises them separately and a grant carries only the scopes you requested, so request both if you read both claims. A deployment that requests only urn:logto:scope:organization_roles has no organizations claim, and every assertOrganizationMember call then denies.

These claims are a snapshot, not a lookup. They were true when Logto issued the ID token and stay frozen until it issues the next one, at most the token's own lifetime, which Logto defaults to an hour. Removing someone from an organization, or taking a role away, does not take effect at once. Nothing re-reads Logto here, by design; not re-reading it is what makes the check free.

Deleting or suspending the user is different. The webhook revokes their sessions within seconds.

When a membership change has to bite at once, do not use these helpers for it. Keep membership in your own table and check that, so the authorization reads current state rather than past state. Shortening the ID token's lifetime in Logto narrows the window; it never closes it.

Organization permissions are the one part Logto puts nowhere but an organization token, audienced urn:logto:organization:{id} and typed at+jwt, which Convex rejects as a request credential. Session mode can mint one for you; see Organization and API-resource tokens.

Organization and API-resource tokens

Session mode only. In bridge mode the Logto SDK is already in the bundle; call useLogto().getOrganizationToken(...) on it.

Reach for this for a non-Convex API you registered with Logto, or to hand an organization-audienced token to something that checks permissions itself. Membership and organization roles are already in the ID token (above) and cost nothing.

const {
  getOrganizationTokenClaims,
  getAccessTokenClaims,
  fetchUserInfo,
  getIdToken,
} = useLogtoAuth();

// A Resource token's scopes are the ones this grant holds.
const { scopes } = await getAccessTokenClaims("https://api.example.com");
if (scopes.includes("invoice:delete")) {
  /* ... */
}

An organization token's scopes is empty. Measured three ways against a real Logto. Logto issues availableScopes ∩ requestedScopes, where availableScopes is the user's organization permissions and requestedScopes defaults to the grant's own scopes. Organization permissions are not OIDC scopes. They are absent from scopes_supported, so the authorize request drops them and they can never be in a grant. The intersection is therefore always empty, and Logto refuses an explicit request for one (invalid_scope, "a refresh grant can only request scopes it already holds").

So do not authorize on getOrganizationTokenClaims(...).scopes. It would deny everyone. Use organization_roles from the ID token, which is free and populated. assertOrganizationRole(ctx, orgId, ["admin"]) above reads it, as of the token's issuance, per the note there. The organization token is still the right thing to send to a service that validates the urn:logto:organization:<id> audience itself.

The component mints the token from the Session's Logto refresh token and hands back what it authorizes, not the token itself. Nothing long-lived enters window. For a caller that must reach a non-Convex API from the browser, pass exposeAccessTokens: true to logtoSessionApi() and use getOrganizationToken(organizationId) / getAccessToken(resource), which return the string. Without the opt-in those reject by name rather than quietly returning nothing.

Both need the app to re-export the new actions:

convex/auth.ts
export const { /* ... */ exchangeToken, fetchUserInfo } = logtoSessionApi(
  components.logto,
  { resources: ["https://api.example.com"] },
);
  • A resource must be declared before sign-in. Logto refuses to issue a token for a resource the grant never named (invalid_target), so resources on logtoSessionApi() is the input, and widening it means signing the user in again. Organizations need nothing there.

  • The component caches minted tokens per session, audience and requested scope set, so a permission check on render does not cost a grant. A revoked session's cache is invisible at once and deleted with it.

  • When the API you called rejects the token, ask for a new one. Every method takes a final { forceRefresh: true }, which skips the cache and replaces what was there. Without it the cache would go on serving a token the resource server has stopped accepting for the rest of its lifetime, with no way to say "not that one". It costs a grant, so it belongs on the failure path:

    let token = await getAccessToken("https://api.example.com");
    let res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
    if (res.status === 401) {
      token = await getAccessToken("https://api.example.com", undefined, {
        forceRefresh: true,
      });
      res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
    }
  • The exchange queues behind a refresh. It spends the same Logto refresh token, so it takes the same claim and can answer the transient refresh_in_flight; retry it.

  • fetchUserInfo() is Logto's live /oidc/me, unlike user, which is the copy the last ID token froze. getIdToken() returns that short-lived bearer itself. fetchUserInfo does the retry above for you; it is the one caller inside the library that consumes a minted token, so when Logto answers 401/403 for a cached one it mints once and tries again. A rejection of a token it just minted is a deployment fault, and it does not spend a second grant on that.

See ADR 0002 for the custody decision and ADR 0003 for why the exchange shares the refresh claim.

assertSubjectHasActiveSession(ctx, component)

Throws (ConvexError, code: "unauthenticated" | "session_revoked") unless the caller is authenticated and that identity's subject has at least one active Session in the component. This is a subject-wide revocation policy check. It does not prove that the current bearer came from one particular Session. To keep every query below Convex's transaction limits, it examines at most eight candidate Sessions; if more candidates remain while bounded revocation cleanup is in progress, it throws the transient session_liveness_scan_incomplete error instead of guessing. Callers may retry that error. assertUserHasActiveSession remains as a deprecated compatibility alias.

Session mode (convex-logto/react-session)

ConvexLogtoSessionProvider

The session mode provider. No Logto SDK, no Logto config in the bundle. It talks to the functions from logtoSessionApi(...).

<ConvexLogtoSessionProvider client={convex} sessionApi={api.auth}>
  <App />
</ConvexLogtoSessionProvider>
PropRequiredPurpose
clientyesYour ConvexReactClient.
sessionApiyesThe module re-exporting logtoSessionApi(...)'s eleven functions, e.g. api.auth (exact names: signIn / callback / refresh / signOut / signOutEverywhere / listSessions / renameSession / revokeSession / exchangeToken / fetchUserInfo / sessionValid).
callbackPathnoRoute that finishes the redirect. Default /callback; exact match against the registered Redirect URI's path.
afterSignInnoWhere to land after sign-in. Default /. signIn({ returnTo }) overrides it.
navigatenoYour router's navigate for soft post-sign-in navigation; prefer replace-style so the callback URL leaves history. Falls back to a hard location.replace.
tokenStoragenoWhere the ID token persists: "session" (default, per-tab; reload without refresh only with its paired component-session marker), "memory" (strictest), "local". The provider clears orphan ID tokens.
deviceBindingnoOpt into ECDSA P-256 proof of possession for refresh and revocation, backed by a non-extractable IndexedDB-held key. Default false; cannot be combined with cookieTransport.
cookieTransportno{ endpoint?, fetch?, deviceBinding? }; use the same-site handler instead of exposing the rotating session token to JavaScript. Its reserved deviceBinding flag only activates the shared incompatibility assertion.
clientDescriptorno{ platform?, os?, browser? } self-reported client description stamped on the session at sign-in for listSessions(). Advisory display data only; the library never reads a User-Agent or IP, and it is never authenticated. Safe as an inline object literal.
initialToken / initialSessionIdnoPaired SSR seed returned by handler.getInitialToken(request).
reactiveRevocationnoSubscribe to session liveness; drop auth live on revocation. Default true.
onAuthErrornoSign-in initiation failures, recoverable callback failures, sign-out failures, and opted-in device-key storage failures. The provider reports a failure before the promise rejects and logs all of them to the console, so void signIn() in a handler stays observable.
onAuthEventnoOpt-in phase timings (see Auth phase events). Absent means nothing is measured.

There is no callback component to add; the provider completes the exchange on callbackPath and replace-navigates into the app itself.

With deviceBinding, the provider fails with an error if IndexedDB is unavailable, sends only the public key during callback, and signs each rotating session token before refresh or revocation. Key eviction makes the next protected operation terminal and causes a clean re-authentication; unbound Sessions remain the default and are unaffected. See the session-mode threat model and DBSC re-evaluation note.

useLogtoAuth() (session)

Returns { isAuthenticated, isLoading, user, signIn, signOut, signOutEverywhere, listSessions, renameSession, revokeSession, getIdToken, getOrganizationTokenClaims, getAccessTokenClaims, getOrganizationToken, getAccessToken, fetchUserInfo }. The first five are the same as bridge mode's hook. Organization and API-resource tokens covers the token methods.

  • signIn({ returnTo? }): one round-trip to mint the sign-in URL, then a full-page redirect. returnTo must be a same-origin path, at most 2048 characters (sign-in is unauthenticated, so what it stores server-side is bounded). The provider delivers a failed action to onAuthError before the promise rejects, so void signIn() remains observable.
  • signOut({ postLogoutRedirectUri?, federated? }): ends the component Session, clears tabs sharing the same localStorage transport, then (unless federated: false) ends the Logto SSO session and returns to postLogoutRedirectUri (default: your origin, which must be a registered Post sign-out redirect URI).
  • signOutEverywhere({ postLogoutRedirectUri? }): derives the caller subject from its session token, records logical revocation before bounded row cleanup, and always completes federated sign-out for this device. Older app modules that have not re-exported the action fail with a message naming the deployment fix.

A federated sign-out resolves once the browser has been told to leave for Logto, not once it has arrived. The local credentials are already gone at that point, so the UI reads "signed out" while the request that ends the Logto session is still in flight. Issuing a hard navigation of your own after await signOut() supersedes it: window.location.assign(...), location.href = ..., a full page load. The SSO session then survives a sign-out that looked like it worked. Logto signs the next visitor to that browser straight back in.

A router push/replace is a soft, same-document navigation and is safe. If you need a hard one, pass the destination as postLogoutRedirectUri and let the sign-out land there itself.

signOut() rejects with SessionSignOutError when local credential cleanup fails twice. convex-logto/react-session and convex-logto/native-session both export the class, so it is instanceof-checkable rather than matched on a message. Two fields say what state the user is in:

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

try {
  await signOut();
} catch (error) {
  if (error instanceof SessionSignOutError) {
    // "revoked": the server session is gone; only this browser's copy lingers.
    // "revocation_failed": the server session is still live. This one matters.
    // "not_present": there was nothing to revoke.
    reportToUser(error.serverSessionStatus);
  }
}

code collapses the same thing to two values: local_cleanup_failed, or local_cleanup_and_server_revocation_failed.

Take the rejection seriously. The engine clears its in-memory auth state first, so the tab reads "signed out" either way, but it throws this error because the durable wipe failed twice, so credentials may still be in browser storage. With revocation_failed the server session is live as well, and a reload can sign the user straight back in. On a shared device that is worth saying out loud rather than swallowing.

  • listSessions(): a snapshot (not a subscription; the credential it authenticates with rotates) of the caller's own sessions, { sessions, truncated }, newest first. Call it again after a rename or revoke.
  • renameSession(sessionId, label): name one of the caller's own sessions; undefined (or a blank string) clears it. The hook rejects a label over 64 characters locally, before any round trip. Rejects with terminal session_not_found for an id that is not the caller's or is already revoked; the component will not confirm that another subject's session exists.
  • revokeSession(sessionId): sign that device out; rejects the same way for an unknown id. Revoking the current session does not clear this browser's credentials; use signOut() for that. Like signOutEverywhere, this is an RP-level boundary. The other device's Logto SSO cookie survives, so it can start a new sign-in. A missing re-export fails with the same named fix.

Bridge mode (convex-logto/react)

ConvexLogtoProvider

Bridge mode's provider: @logto/react + Convex + the auto sign-in callback. Pass your public Logto config statically (the default; no config round-trip), or configQuery to resolve it from the backend at runtime. Exactly one of the two.

<ConvexLogtoProvider
  client={convex}
  config={{
    endpoint: import.meta.env.VITE_LOGTO_ENDPOINT,
    appId: import.meta.env.VITE_LOGTO_APP_ID,
  }}
>
  <App />
</ConvexLogtoProvider>
PropRequiredPurpose
clientyesYour ConvexReactClient.
configone ofStatic { endpoint, appId, allowInsecureHttp? }. Both OAuth values are public; HTTPS is required except loopback or explicit insecure opt-in.
configQueryone ofRef to the logtoConfigQuery() export, e.g. api.logto.config, for runtime-resolved config.
fallbacknoRendered while configQuery loads (children mount once, when ready). Default null. No effect with static config.
callbackPathnoThe route that finishes the OIDC redirect. Default /callback. Must match a registered Redirect URI's path; only this exact path runs callback handling.
afterSignInnoWhere to go once sign-in completes. Default /. signIn({ returnTo }) overrides it.
navigatenoSoft navigation (your router's navigate). Optional for plain Vite; recommended for any router (TanStack / Next) so post-sign-in is a soft navigation rather than a full-page reload that drops router state. Prefer a replace-style navigate. Falls back to a hard location.replace.
onAuthErrornoCalled on sign-in initiation failures (Logto unreachable, blocked storage, in-app browser restrictions), recoverable callback failures (stale/replayed callback, setup errors like invalid_scope), and sign-out failures; @logto/react catches those into its own state and resolves the promise, leaving the user signed in with the tokens still in place. The provider also logs errors to the console.
discoveryCachenoCache OIDC discovery + JWKS in sessionStorage. Default true.
onAuthEventnoOpt-in phase timings (see Auth phase events). Bridge mode emits bootstrap_start and convex_authenticated, plus config_loaded in configQuery mode only. Absent means nothing is measured.
scopes / resourcesnoExtra OIDC scopes / API resources. openid, profile, offline_access, and email are always included.

Safe to render on the server. Nothing touches window during render, so you need no stub or mount gate. In the Next.js App Router, mark the component that imports it "use client", as with any hook.

The sign-in redirect lands on callbackPath (default /callback); add a route there that just renders.

useLogtoAuth() (bridge)

Returns { isAuthenticated, isLoading, user, signIn, signOut }.

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

const { isAuthenticated, isLoading, user, signIn, signOut } = useLogtoAuth();

signIn({ returnTo }) starts sign-in and lands the user on returnTo after it completes. returnTo must be a same-origin path starting with / (the provider rejects anything else, to prevent open redirects). Without returnTo, the provider uses afterSignIn. To land the callback on another path, set callbackPath on the provider; there is no per-call redirect URI. Failures reach onAuthError and the console either way, including the ones @logto/react catches into SDK state and never rejects with. The ones it does reject with, the provider reports first and then rethrows, so a fire-and-forget call wants a handler: void signOut().catch(() => {}). A failed sign-out is worth acting on; the SDK reaches OIDC discovery before it clears tokens, so an unreachable Logto leaves the user signed in.

Auth phase events

Bridge mode, session mode, and both native entries take the same optional onAuthEvent for measuring how long a user waits before the first authenticated query, and which phase regressed. Absent, nothing is measured; no timer runs and no clock is read.

<ConvexLogtoSessionProvider
  client={convex}
  sessionApi={api.auth}
  onAuthEvent={(event) => analytics.track(event.phase, event.elapsedMs)}
>
  <App />
</ConvexLogtoSessionProvider>

An event is { phase, elapsedMs, source?, errorKind? }. elapsedMs counts from bootstrap_start on a monotonic clock where the platform has one. Events carry no token, no user identity, and no URL, so you can forward one to an analytics backend as-is.

PhaseWhen
bootstrap_startThe provider mounted; everything else is measured from here.
config_loadedBridge mode with configQuery: the one config fetch resolved.
session_restoredThe mount settled authenticated. source is cache (a still-fresh stored token), ssr (the token the server rendered with), refresh, or callback.
unauthenticatedThe mount settled with no session.
convex_authenticatedConvex accepted the token; the first authenticated query can run. Emitted once per mount, even if Convex later reconnects.
refresh_started / refresh_succeededA token refresh, including silent ones long after mount.
refresh_failedWith errorKind: terminal (the session is gone) or transient (retryable).
refresh_abandonedA sign-out or revocation landed while the refresh was in flight, so its result was discarded. Exactly one of the three end phases follows every refresh_started, so a paired span never stays open.
revokedThe reactive sessionValid subscription reported the session dead.
signed_outThis client signed out, or another tab did (source: "cross-tab").

Only the first settle reports session_restored / unauthenticated; later transitions have their own phases, so a long-lived tab never looks like it re-mounted. The provider catches and logs a handler that throws; telemetry can never fail an authentication.

Bridge mode emits bootstrap_start and convex_authenticated, plus config_loaded only in configQuery mode, the one mode with a fetch to time. The Logto SDK owns the credential lifecycle there, so the settle and refresh phases are session mode's. Passing the handler on a later render is fine. The provider reads it per event, so nothing is rebuilt, and it then reports from that point on rather than replaying phases it missed.

Native session mode (convex-logto/native-session)

The React Native / Expo adapter for the same SessionAuthEngine and logtoSessionApi(...) actions as web session mode. It stores the rotating session token, OAuth state, and short-lived ID token in deployment-namespaced Expo SecureStore, and completes OAuth with expo-web-browser plus the app's deep-link redirect. There is no callback route, cookie transport, or software device-binding option.

<ConvexLogtoSessionProvider
  client={convex}
  sessionApi={api.auth}
  redirectUri="io.logto://callback"
>
  <App />
</ConvexLogtoSessionProvider>
PropRequiredPurpose
clientyesYour ConvexReactClient.
sessionApiyesThe module re-exporting all eleven logtoSessionApi(...) functions.
redirectUriyesCustom-scheme or universal-link callback registered as both a Redirect URI and Post sign-out redirect URI in Logto.
reactiveRevocationnoSubscribe to sessionValid and clear SecureStore as soon as the session is revoked. Default true.
onAuthErrornoReceives sign-in initiation failures plus recoverable OAuth, SecureStore, and system-browser failures; the provider reports initiation failures before signIn() rejects and logs every error.
onAuthEventnoOpt-in phase timings (see Auth phase events). Absent means nothing is measured.

useLogtoAuth() returns { isAuthenticated, isLoading, user, signIn, completeSignIn, signOut, signOutEverywhere, listSessions, renameSession, revokeSession }, with the same session-management semantics as web session mode and an optional clientDescriptor prop for the device description shown in the list. signIn() opens and completes the system-browser flow in place. completeSignIn(url) finishes a sign-in whose deep link came back outside that flow; see Recovering a reclaimed sign-in. signOut({ postLogoutRedirectUri?, federated? }) clears SecureStore and revokes the server session, then ends Logto browser SSO unless federated: false. signOutEverywhere({ postLogoutRedirectUri? }) clears SecureStore, kills every component session for the caller subject, and ends this device's Logto browser SSO session.

The provider persists the ID token in SecureStore so a cold start authenticates with no refresh while its paired component-session marker remains. It clears an orphan bearer. This is the native equivalent of choosing web tokenStorage="local", but the bearer stays inside the OS encrypted keystore. See Expo (React Native) for setup and the web/native differences.

Native bridge mode (convex-logto/native)

For React Native / Expo, built on @logto/rn. The backend exports above are the same; only the frontend provider differs. Full walkthrough in React Native (Expo).

ConvexLogtoProvider (native)

Wires Logto to Convex on native. Same config XOR configQuery model as the web provider. There is no callback route; signIn opens the system browser and resolves when the deep link returns.

<ConvexLogtoProvider
  client={convex}
  config={{
    endpoint: process.env.EXPO_PUBLIC_LOGTO_ENDPOINT!,
    appId: process.env.EXPO_PUBLIC_LOGTO_APP_ID!,
  }}
  redirectUri="io.logto://callback"
>
  <App />
</ConvexLogtoProvider>
PropRequiredPurpose
clientyesYour ConvexReactClient.
configone ofStatic { endpoint, appId, allowInsecureHttp? }. Both OAuth values are public; HTTPS is required except loopback or explicit insecure opt-in.
configQueryone ofRef to the logtoConfigQuery() export, for runtime-resolved config.
redirectUriyesNative callback URI: your app.json scheme plus a path (e.g. io.logto://callback), registered on the Logto app. The default for signIn().
fallbacknoRendered while configQuery loads, before the Convex provider mounts. Default null.
scopes / resourcesnoExtra OIDC scopes / API resources. openid, profile, offline_access, and email are always included.
onAuthEventnoOpt-in phase timings (see Auth phase events): bootstrap_start, convex_authenticated, plus config_loaded in configQuery mode. Absent means nothing is measured.
onAuthErrornoCalled when sign-in or sign-out fails (Logto unreachable, the user dismissing the system browser, an expired session). @logto/rn rejects rather than storing the error, so without this a void signIn() in an onPress is an unhandled rejection and nothing else. Reported before the promise rejects; also logged to the console.

useLogtoAuth() (native)

Returns { isAuthenticated, isLoading, user, signIn, signOut }. signIn({ redirectUri? }) defaults to the provider's redirectUri; signOut({ postLogoutRedirectUri? }) revokes the tokens and clears local storage (no browser / federated sign-out on native). Both report a failure to the provider's onAuthError, and to the console, before rejecting, so a failure in an onPress is observable rather than silent. The promise still rejects: void signIn().catch(() => {}) if you do not want an unhandled rejection alongside the report.

const { isAuthenticated, user, signIn, signOut } = useLogtoAuth();

On this page