convex-logto

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-session is 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/native is 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:

app.json
{
  "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-browser

expo-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 URIsio.logto://callback
  • Post sign-out redirect URIsio.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-secret

The app carries no Logto config at all, not even the endpoint.

3. Wire Convex

The same three files as web session mode:

convex/convex.config.ts
import { defineApp } from "convex/server";
import logto from "convex-logto/convex.config";

const app = defineApp();
app.use(logto);
export default app;
convex/auth.config.ts
import { logtoAuthConfig } from "convex-logto";
export default { providers: [logtoAuthConfig()] };
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);

4. Mount the provider

Expo exposes only EXPO_PUBLIC_* env vars to the bundle. Pass redirectUri (the scheme callback) so signIn() has a default:

App.tsx
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.

Main.tsx
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 sessionNative session
Entryconvex-logto/react-sessionconvex-logto/native-session
OAuth return/callback routesystem browser → deep link
Session credentiallocalStorage, optional HttpOnly cookieExpo SecureStore
ID token defaultsessionStorageExpo SecureStore for fast cold starts
Cross-context coordinationstorage events + Web Locksone installed app / SecureStore
Cookie transportoptional, same-site onlyunavailable
Software device bindingoptional on webnone, 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 → ApplicationsCreate 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 URIsio.logto://callback
  • Post sign-out redirect URIsio.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-id

And in the app's env. Both values are public:

EXPO_PUBLIC_LOGTO_ENDPOINT=https://auth.example.com
EXPO_PUBLIC_LOGTO_APP_ID=your-app-id

3. Wire Convex

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

4. Wrap your app

App.tsx
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 callbacka /callback route you addnone; signIn resolves in place
signIn() default${origin}/callbackthe provider's redirectUri
Sign-outfederated redirect to originrevokes tokens + clears storage (no browser)
LoadingSSR-safe; fallback only while a configQuery loadsfallback 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.

On this page