Expo (React Native)
Session mode or bridge mode in a Convex Expo app. The system browser signs in and an app deep link brings the user back; there is no callback route.
There are two React Native entries:
convex-logto/native-sessionis session mode, the recommended one. The Convex component owns the Logto refresh token; Expo SecureStore holds only a rotating application session token and a short-lived ID token.convex-logto/nativeis bridge mode, built on@logto/rn. The Logto SDK owns the refresh token on the device.
Both sign in through the system browser and return through an app deep link,
with no callback route. Both use the same backend as the web, so me queries,
webhook sync, and ctx.auth.getUserIdentity() are
unchanged.
Sign-in returns through your custom io.logto:// scheme, which Expo Go on
Android can't register, so use a development build (npx expo run:android /
run:ios, or an EAS dev build).
Before either mode
Rotate the signing key to RSA. Convex only accepts ID tokens signed with
RS256 (or EdDSA). Logto signs with ES384 by default, which Convex
rejects without an error. Sign-in looks like it works, but
ctx.auth.getUserIdentity() returns null. In the Logto Console, open
Tenant settings → OIDC configs, click Rotate private keys, and choose
RSA. Once per tenant; Logto keeps the old key during a transition.
Set your URL scheme. The native redirect URI is your app.json scheme
plus a path:
{
"expo": {
"scheme": "io.logto"
}
}On Android you must set the scheme here; iOS listens for it regardless. It must match the redirect URI you register on the Logto app below.
Session mode
Install
pnpm add convex-logto
npx expo install expo-secure-store expo-web-browserexpo-secure-store and expo-web-browser are optional peers so bridge-mode
and web-only apps do not install them. This entry does not use @logto/rn.
1. Create a Logto app
Create a Traditional web application, then register the app deep link in both lists:
- Redirect URIs →
io.logto://callback - Post sign-out redirect URIs →
io.logto://callback
Note the endpoint, App ID, and App Secret.
2. Configure the deployment
npx convex env set LOGTO_ENDPOINT https://auth.example.com
npx convex env set LOGTO_APP_ID your-app-id
npx convex env set LOGTO_CLIENT_SECRET your-app-secretThe app carries no Logto config at all, not even the endpoint.
3. Wire Convex
The same three files as web session mode:
import { defineApp } from "convex/server";
import logto from "convex-logto/convex.config";
const app = defineApp();
app.use(logto);
export default app;import { logtoAuthConfig } from "convex-logto";
export default { providers: [logtoAuthConfig()] };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);4. Mount the provider
Expo exposes only EXPO_PUBLIC_* env vars to the bundle. Pass redirectUri
(the scheme callback) so signIn() has a default:
import { ConvexReactClient } from "convex/react";
import { ConvexLogtoSessionProvider } from "convex-logto/native-session";
import { api } from "./convex/_generated/api";
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!, {
unsavedChangesWarning: false,
initialAuthTokenReuse: true,
});
export default function App() {
return (
<ConvexLogtoSessionProvider
client={convex}
sessionApi={api.auth}
redirectUri="io.logto://callback"
>
<Main />
</ConvexLogtoSessionProvider>
);
}5. Sign in, and read the user
import { useLogtoAuth } from "convex-logto/native-session";
import { Button, Text } from "react-native";
function Main() {
const { isAuthenticated, isLoading, user, signIn, signOut } = useLogtoAuth();
if (isLoading) return <Text>Signing in…</Text>;
return isAuthenticated ? (
<Button title={`Sign out (${user?.email ?? user?.sub})`} onPress={() => void signOut()} />
) : (
<Button title="Sign in" onPress={() => void signIn()} />
);
}signIn() asks the Convex action for an authorize URL, opens it with
WebBrowser.openAuthSessionAsync, verifies that the returned state matches
the single-use state stashed before the browser opened, and exchanges the code
in place. There is no /callback screen or router integration.
signOut() clears SecureStore, revokes the server session, and opens Logto's
end-session URL in the system browser unless you pass federated: false.
signOutEverywhere({ postLogoutRedirectUri? }) records subject-wide revocation,
lets the sessionValid subscription sign out other live devices, and always
ends this device's Logto browser session. The me query from the Quick
start works unchanged.
Recovering a reclaimed sign-in
That whole flow lives in one in-memory promise. If the OS reclaims the app while
Logto has the browser, routine on a low-memory Android device, the promise dies
with the process and the redirect arrives as a cold-start deep link instead. Hand
it to completeSignIn(url) and the exchange finishes as normal; without it the
user comes back signed in at Logto and signed out in the app, with no error.
Web needs no equivalent, because the callback is in the URL and the provider
re-reads it on the next mount.
import * as Linking from "expo-linking";
import { useEffect } from "react";
import { useLogtoAuth } from "convex-logto/native-session";
const { completeSignIn } = useLogtoAuth();
useEffect(() => {
void Linking.getInitialURL().then((url) => url && completeSignIn(url));
const subscription = Linking.addEventListener("url", ({ url }) => {
void completeSignIn(url);
});
return () => subscription.remove();
}, [completeSignIn]);Pass every deep link through. completeSignIn ignores anything that is not
this app's redirectUri, or that carries no OIDC response, without disturbing a
sign-in already in progress, and delivering the same URL twice (both entry
points fire on some Android returns) completes it once. A user who cancelled
in the browser is not recoverable this way, on purpose. Cancelling discards the
OIDC state, so a later deep link cannot replay it. Tapping Sign in again is
instant, since Logto's browser SSO session is still there.
What lives on the device
Native session mode keeps the rotating session token, the OAuth state stash, and the short-lived ID token in deployment-namespaced SecureStore, under the iOS Keychain or the Android encrypted keystore. Persisting the ID token is a cold-start choice. While the token is fresh and its paired component Session marker is still present, the app authenticates on launch without a refresh; a stale one causes the normal server refresh, which rolls the SecureStore session token before Convex receives the new ID token. The provider clears an orphan ID token.
Reactive revocation is on by default and uses the same sessionValid
subscription as the web provider. There is no tokenStorage,
cookieTransport, or deviceBinding prop; SecureStore already uses the OS
keystore. If SecureStore is unavailable or fails, the provider reports the
failure through onAuthError and never falls back to AsyncStorage or an
unbound plaintext credential.
| Web session | Native session | |
|---|---|---|
| Entry | convex-logto/react-session | convex-logto/native-session |
| OAuth return | /callback route | system browser → deep link |
| Session credential | localStorage, optional HttpOnly cookie | Expo SecureStore |
| ID token default | sessionStorage | Expo SecureStore for fast cold starts |
| Cross-context coordination | storage events + Web Locks | one installed app / SecureStore |
| Cookie transport | optional, same-site only | unavailable |
| Software device binding | optional on web | none, on purpose |
Full runnable app: examples/expo-session,
the native counterpart of vite-react-session, with the same eleven-function
convex/auth.ts, a device list, and a side-by-side comparison against the
bridge-mode examples/expo.
Bridge mode
Install
pnpm add convex-logto @logto/rn
npx expo install expo-crypto expo-secure-store expo-web-browser @react-native-async-storage/async-storage@logto/rn needs those four Expo modules; expo install picks the versions
matching your SDK.
1. Create a Logto app
In Logto Console → Applications → Create application → under Single page
app pick React, not a Third-party app (it withholds the profile /
email scopes this package needs, so sign-in fails with invalid_scope).
Note the endpoint and App ID, and register the same two deep links as above:
- Redirect URIs →
io.logto://callback - Post sign-out redirect URIs →
io.logto://callback
2. Set the config
On the Convex deployment, which auth.config.ts reads:
npx convex env set LOGTO_ENDPOINT https://auth.example.com
npx convex env set LOGTO_APP_ID your-app-idAnd in the app's env. Both values are public:
EXPO_PUBLIC_LOGTO_ENDPOINT=https://auth.example.com
EXPO_PUBLIC_LOGTO_APP_ID=your-app-id3. Wire Convex
import { logtoAuthConfig } from "convex-logto";
export default { providers: [logtoAuthConfig()] };4. Wrap your app
import { ConvexLogtoProvider } from "convex-logto/native";
import { ConvexReactClient } from "convex/react";
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!, {
unsavedChangesWarning: false,
});
export default function App() {
return (
<ConvexLogtoProvider
client={convex}
config={{
endpoint: process.env.EXPO_PUBLIC_LOGTO_ENDPOINT!,
appId: process.env.EXPO_PUBLIC_LOGTO_APP_ID!,
}}
redirectUri="io.logto://callback"
>
<Main />
</ConvexLogtoProvider>
);
}To keep the two values out of the bundle, export logtoConfigQuery() from
convex/logto.ts and pass configQuery={api.logto.config} instead; the
provider renders fallback during that one config fetch. The runnable
examples/expo
does it that way.
5. Sign in, and read the user
import { useLogtoAuth } from "convex-logto/native";
import { Button, Text } from "react-native";
function Main() {
const { isAuthenticated, isLoading, user, signIn, signOut } = useLogtoAuth();
if (isLoading) return <Text>Signing in…</Text>;
return isAuthenticated ? (
<Button title={`Sign out (${user?.email ?? user?.sub})`} onPress={() => void signOut()} />
) : (
// signIn() defaults to the provider's redirectUri
<Button title="Sign in" onPress={() => void signIn()} />
);
}@logto/rn's signIn opens the system browser and resolves when the deep link
returns. signOut() revokes the tokens and clears storage; there is no
federated sign-out on native bridge mode. Both report a failure to
onAuthError before rejecting.
How native bridge mode differs from web
Web (convex-logto/react) | Native (convex-logto/native) | |
|---|---|---|
| SDK | @logto/react | @logto/rn |
| Sign-in callback | a /callback route you add | none; signIn resolves in place |
signIn() default | ${origin}/callback | the provider's redirectUri |
| Sign-out | federated redirect to origin | revokes tokens + clears storage (no browser) |
| Loading | SSR-safe; fallback only while a configQuery loads | fallback covers the one-time configQuery fetch |
Expo SDK and @logto/rn versions
This repository tests @logto/rn@1.2 with Expo SDK 56. Keep the Logto adapter
and Expo modules within their declared peer ranges when upgrading.
Next.js
Mount the provider behind a "use client" boundary in the Next.js App Router. Session mode adds a cookie transport and server rendering with a real identity.
Session mode
Keep the Logto refresh token out of the browser. A Convex component holds it, rotates application session tokens, and revokes sessions reactively.