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 mode | Session mode | |
|---|---|---|
| Logto app type | Single-page app | Traditional web |
| Refresh token lives | browser localStorage | Convex component (never leaves) |
| XSS steals | a ~2-week refresh token | a ~1-hour ID token plus a rotating application credential; the component contains late reuse on presentation |
| Sign-out elsewhere / user suspended | at token expiry | pushed live (reactive revocation) |
| Frontend dependencies | @logto/react | none beyond convex + react |
| Logto config in the bundle | endpoint + app id | nothing |
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:
- A Traditional web app in Logto, with
http://localhost:5173/callbackas a Redirect URI andhttp://localhost:5173as 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. app.use(logto)inconvex/convex.config.ts, importinglogtofromconvex-logto/convex.config.LOGTO_ENDPOINT,LOGTO_APP_ID, andLOGTO_CLIENT_SECRETset withnpx 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 bothlogtoAuthConfigandlogtoSessionApi; prefer terminating TLS instead.convex/auth.config.tswith the same one line as bridge mode,logtoAuthConfig(). Session mode validates the same OIDC ID token, just issued to the Traditional web app.convex/auth.tsre-exporting the eleven functions fromlogtoSessionApi(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.<ConvexLogtoSessionProvider client={convex} sessionApi={api.auth}>fromconvex-logto/react-session, with{ initialAuthTokenReuse: true }on theConvexReactClient. 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 tickAll 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.
HttpOnly cookie transport (optional)
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=16416000JavaScript 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:
| Route | Purpose |
|---|---|
sign-in | Create the Logto authorization URL. |
callback | Exchange the authorization code and set the first cookie. |
token | Rotate the cookie and return a fresh ID token. |
sign-out | Delete the component Session, expire the cookie, and optionally end SSO. |
sessions | List, rename, or revoke the caller's own sessions (op selects). Never expires the cookie; revoking another device must not sign this one out. |
tokens | Exchange 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:
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:
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:
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.
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 versus device binding
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):
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:
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
- Sign-in.
signIn()asks the component for an authorize URL. The OIDCstateand PKCE verifier stay server-side (a one-time, 10-minute transaction); the browser also pins thestateto the signing-in tab, so the provider refuses a forged or replayed callback before any exchange. - 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.
- 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.
- 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.
- Sign-out. Clears local state first (other tabs sharing localStorage
follow via the
storageevent), removes the component Session, then ends the current browser's SSO session unless you disable federated sign-out. An app whose sign-in route callssignIn()on mount reaches it in that window, sosignIn()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
| Prop | Default | |
|---|---|---|
client | none | Your ConvexReactClient. |
sessionApi | none | The 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. |
navigate | hard replace | Your 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. |
deviceBinding | false | Bind refresh and revocation operations to a non-extractable IndexedDB-held ECDSA key. Cannot be combined with cookieTransport. |
cookieTransport | none | { 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. |
clientDescriptor | none | { platform?, os?, browser? }, the self-reported device description listSessions() shows. Display text only; never authenticated. |
initialToken / initialSessionId | none | Paired values from handler.getInitialToken(request) for authenticated SSR/hydration. |
reactiveRevocation | true | Subscribe to session liveness and drop auth live on revocation. |
onAuthError | none | Sign-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. |
onAuthEvent | none | Opt-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_targetfor a resource the grant never mentioned, soresourcesis 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_flightwhile 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.
@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.