API reference
Every export from convex-logto and its web, native, and session-mode entries, with signatures.
Exports
| Export | From | Purpose |
|---|---|---|
logtoAuthConfig(opts?) | convex-logto | Provider entry for auth.config.ts. Reads LOGTO_ENDPOINT / LOGTO_APP_ID. |
logtoConfigQuery(opts?) | convex-logto | Public query serving { endpoint, appId, allowInsecureHttp? } to a bridge-mode frontend that resolves its config at runtime. |
logtoSync<DataModel>(handlers) | convex-logto | Returns { sync }, an internal mutation mapping user events to your tables. |
registerLogtoWebhook(http, sync, opts?) | convex-logto | Registers the verified webhook route. Reads LOGTO_WEBHOOK_SIGNING_KEY. |
verifyLogtoSignature(key, body, sig) | convex-logto | Low-level signature check, for custom routing. |
registerLogtoBackchannelLogout(http, opts) | convex-logto | Registers the verified OIDC back-channel logout route for session mode. |
createLogtoBackchannelLogoutHandler(opts) | convex-logto | Builds the same Convex HTTP action without registering a path. |
verifyLogtoLogoutToken(token, opts?) | convex-logto | Low-level Web Crypto/JWKS validation for an OIDC Logout Token. |
logtoSessionApi(component, opts?) | convex-logto | Session mode: builds the eleven public auth functions backed by the session component. |
assertSubjectHasActiveSession(ctx, component) | convex-logto | Session mode: require at least one active component Session for the authenticated subject; does not bind this bearer to one Session. |
assertUserHasActiveSession(ctx, component) | convex-logto | Deprecated compatibility alias for assertSubjectHasActiveSession. |
createLogtoSessionCookieHandler(opts) | convex-logto | Six-route standard-fetch handler for the optional same-site HttpOnly cookie transport. |
createLogtoSessionCookieTransport(api, opts?) | convex-logto | Framework-free browser adapter used by the session provider's cookieTransport prop. |
assertLogtoSessionCookieCompatibility(opts) | convex-logto | Loud Safari/device-binding compatibility guard. |
readLogtoIdTokenCookie(source) | convex-logto | Reads 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-logto | Fixed cookie names/base path and CSRF header/value constants. |
assertOrganizationMember / assertOrganizationRole | convex-logto | Organization authorization from the ID token Convex already validated. |
logtoOrganizations / logtoOrganizationRoles | convex-logto | The same claims, read rather than asserted. |
ConvexLogtoProvider | convex-logto/react | Bridge mode's provider, on @logto/react. Static config or backend configQuery. |
useLogtoAuth() | convex-logto/react | { isAuthenticated, isLoading, user, signIn, signOut }. |
| default | convex-logto/convex.config | The session component, for app.use(logto) in convex/convex.config.ts. |
ConvexLogtoSessionProvider | convex-logto/react-session | Session mode's provider. No Logto SDK; talks to your logtoSessionApi functions. |
useLogtoAuth() | convex-logto/react-session | Session auth plus signOutEverywhere({ postLogoutRedirectUri? }), listSessions() / renameSession() / revokeSession(), and getIdToken() / getOrganizationTokenClaims() / getAccessTokenClaims() / fetchUserInfo(). |
SessionSignOutError | convex-logto/react-session, convex-logto/native-session | The error signOut() rejects with when local credential cleanup fails twice; serverSessionStatus says whether the server session survived. |
LogtoUserClaims | convex-logto | The 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. |
ConvexLogtoProvider | convex-logto/native | React Native / Expo provider (on @logto/rn). Same config / configQuery model; no callback route. |
useLogtoAuth() | convex-logto/native | Native { isAuthenticated, isLoading, user, signIn, signOut }; signIn({ redirectUri? }) / signOut({ postLogoutRedirectUri? }). |
ConvexLogtoSessionProvider | convex-logto/native-session | React Native / Expo session provider using SecureStore + the system browser. |
useLogtoAuth() | convex-logto/native-session | Native 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.
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.
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.
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.
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.
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 sameapi.authmodule 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. SeereadLogtoIdTokenCookie.
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 terminalorganization_forbiddenunless 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'sviewercannot 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
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:
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), soresourcesonlogtoSessionApi()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, unlikeuser, which is the copy the last ID token froze.getIdToken()returns that short-lived bearer itself.fetchUserInfodoes the retry above for you; it is the one caller inside the library that consumes a minted token, so when Logto answers401/403for 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>| Prop | Required | Purpose |
|---|---|---|
client | yes | Your ConvexReactClient. |
sessionApi | yes | The module re-exporting logtoSessionApi(...)'s eleven functions, e.g. api.auth (exact names: signIn / callback / refresh / signOut / signOutEverywhere / listSessions / renameSession / revokeSession / exchangeToken / fetchUserInfo / sessionValid). |
callbackPath | no | Route that finishes the redirect. Default /callback; exact match against the registered Redirect URI's path. |
afterSignIn | no | Where to land after sign-in. Default /. signIn({ returnTo }) overrides it. |
navigate | no | Your 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. |
tokenStorage | no | Where 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. |
deviceBinding | no | Opt 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. |
cookieTransport | no | { 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. |
clientDescriptor | no | { 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 / initialSessionId | no | Paired SSR seed returned by handler.getInitialToken(request). |
reactiveRevocation | no | Subscribe to session liveness; drop auth live on revocation. Default true. |
onAuthError | no | Sign-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. |
onAuthEvent | no | Opt-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.returnTomust 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 toonAuthErrorbefore the promise rejects, sovoid signIn()remains observable.signOut({ postLogoutRedirectUri?, federated? }): ends the component Session, clears tabs sharing the same localStorage transport, then (unlessfederated: false) ends the Logto SSO session and returns topostLogoutRedirectUri(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 terminalsession_not_foundfor 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; usesignOut()for that. LikesignOutEverywhere, 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>| Prop | Required | Purpose |
|---|---|---|
client | yes | Your ConvexReactClient. |
config | one of | Static { endpoint, appId, allowInsecureHttp? }. Both OAuth values are public; HTTPS is required except loopback or explicit insecure opt-in. |
configQuery | one of | Ref to the logtoConfigQuery() export, e.g. api.logto.config, for runtime-resolved config. |
fallback | no | Rendered while configQuery loads (children mount once, when ready). Default null. No effect with static config. |
callbackPath | no | The route that finishes the OIDC redirect. Default /callback. Must match a registered Redirect URI's path; only this exact path runs callback handling. |
afterSignIn | no | Where to go once sign-in completes. Default /. signIn({ returnTo }) overrides it. |
navigate | no | Soft 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. |
onAuthError | no | Called 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. |
discoveryCache | no | Cache OIDC discovery + JWKS in sessionStorage. Default true. |
onAuthEvent | no | Opt-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 / resources | no | Extra 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.
| Phase | When |
|---|---|
bootstrap_start | The provider mounted; everything else is measured from here. |
config_loaded | Bridge mode with configQuery: the one config fetch resolved. |
session_restored | The mount settled authenticated. source is cache (a still-fresh stored token), ssr (the token the server rendered with), refresh, or callback. |
unauthenticated | The mount settled with no session. |
convex_authenticated | Convex accepted the token; the first authenticated query can run. Emitted once per mount, even if Convex later reconnects. |
refresh_started / refresh_succeeded | A token refresh, including silent ones long after mount. |
refresh_failed | With errorKind: terminal (the session is gone) or transient (retryable). |
refresh_abandoned | A 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. |
revoked | The reactive sessionValid subscription reported the session dead. |
signed_out | This 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>| Prop | Required | Purpose |
|---|---|---|
client | yes | Your ConvexReactClient. |
sessionApi | yes | The module re-exporting all eleven logtoSessionApi(...) functions. |
redirectUri | yes | Custom-scheme or universal-link callback registered as both a Redirect URI and Post sign-out redirect URI in Logto. |
reactiveRevocation | no | Subscribe to sessionValid and clear SecureStore as soon as the session is revoked. Default true. |
onAuthError | no | Receives sign-in initiation failures plus recoverable OAuth, SecureStore, and system-browser failures; the provider reports initiation failures before signIn() rejects and logs every error. |
onAuthEvent | no | Opt-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>| Prop | Required | Purpose |
|---|---|---|
client | yes | Your ConvexReactClient. |
config | one of | Static { endpoint, appId, allowInsecureHttp? }. Both OAuth values are public; HTTPS is required except loopback or explicit insecure opt-in. |
configQuery | one of | Ref to the logtoConfigQuery() export, for runtime-resolved config. |
redirectUri | yes | Native callback URI: your app.json scheme plus a path (e.g. io.logto://callback), registered on the Logto app. The default for signIn(). |
fallback | no | Rendered while configQuery loads, before the Convex provider mounts. Default null. |
scopes / resources | no | Extra OIDC scopes / API resources. openid, profile, offline_access, and email are always included. |
onAuthEvent | no | Opt-in phase timings (see Auth phase events): bootstrap_start, convex_authenticated, plus config_loaded in configQuery mode. Absent means nothing is measured. |
onAuthError | no | Called 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();