Skip to main content
Expo is a great fit for Ave’s public-client OAuth flow. The clean setup is:
  • expo-auth-session — builds the Ave authorize URL and runs PKCE; promptAsync() opens the in-app system browser (Chrome Custom Tabs on Android, SFSafariViewController on iOS) via expo-web-browser, not a raw WebView and not Safari/Chrome leaving your app without a return path.
  • expo-web-browser — session completion (maybeCompleteAuthSession), optional warmUpAsync / coolDownAsync on Android
  • @ave-id/sdk — exchange the code, refresh tokens, optional Ave Session
Do not use Linking.openURL(authorizeUrl) alone for login — you lose the secure auth-session handoff. Use useAuthRequest + promptAsync() (or WebBrowser.openAuthSessionAsync with the same redirect URL you registered). If you’re using Convex too, see Convex custom auth. The short version: pass Convex the Ave id_token, not access_token_jwt.
Expo apps are public clients. Do not ship a clientSecret in the app. Use the authorization code flow with PKCE.

Install dependencies

bunx expo install expo-auth-session expo-web-browser expo-crypto
bun add @ave-id/sdk
Expo’s current AuthSession docs note that expo-crypto is a peer dependency of expo-auth-session.

Configure the Ave SDK for Expo

Expo native does not provide Web Crypto for SHA-256 (needed for PKCE code challenge). Use expo-crypto, not browser crypto.subtle, for digest and getRandomValues.
import * as ExpoCrypto from "expo-crypto";
import { configureAveSdkForExpo } from "@ave-id/sdk/expo-session";

configureAveSdkForExpo(ExpoCrypto);
This is equivalent to:
import { configureCryptoRuntime, createExpoCryptoRuntime } from "@ave-id/sdk";

configureCryptoRuntime(createExpoCryptoRuntime(ExpoCrypto));
Put it near app bootstrap, before any PKCE helpers (generateCodeChallenge, startPkceLogin, or AuthSession’s PKCE).

System browser (expo-web-browser)

Ave MUST open in the Expo-managed browser session so the redirect returns to your app.
  1. Import at root (before your first auth prompt):
import * as WebBrowser from "expo-web-browser";
import { initExpoOAuthBrowserSession, warmUpExpoAuthBrowser } from "@ave-id/sdk/expo-session";

initExpoOAuthBrowserSession(WebBrowser);
  1. Android: warm the custom tab while your login screen is mounted (same pattern as Expo’s own AuthSession examples):
useEffect(() => {
  const dispose = warmUpExpoAuthBrowser(WebBrowser);
  return dispose;
}, []);
  1. AuthSession.useAuthRequest + promptAsync() — this is what actually opens the authorization URL in expo-web-browser. You do not need to call openBrowserAsync yourself for the standard flow.
verifyJwt() is not supported on Expo native today. The Ave SDK skips client-side JWT verification in Expo callback helpers so your login flow does not crash, but if you need signature verification you should do it on your server or let a verifier like Convex validate the id_token.

Register your redirect URI

First, give your app a custom scheme:
{
  "expo": {
    "scheme": "aveexpo"
  }
}
Then build the redirect URI you will use in the app:
import * as AuthSession from "expo-auth-session";

const redirectUri = AuthSession.makeRedirectUri({
  scheme: "aveexpo",
  path: "oauth/callback",
});
Register that exact redirect URI in the Ave developer portal for your app.
In Expo development builds and standalone apps, makeRedirectUri() uses your app scheme. On web it uses the current website origin. Keep the redirect URI you register in Ave aligned with the platform you are testing.

Ave Session + SecureStore (single-flight refresh)

For offline_access, token refresh, and optional #app_key (E2EE) merged from the redirect URL string:
import * as ExpoCrypto from "expo-crypto";
import * as SecureStore from "expo-secure-store";
import {
  AveSession,
  completeExpoOAuthCallback,
  configureAveSdkForExpo,
  createSecureStoreAdapter,
  wireAveSessionToConvex,
} from "@ave-id/sdk/expo-session";
import { ConvexReactClient } from "convex/react";

configureAveSdkForExpo(ExpoCrypto);

const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!);

const session = new AveSession({
  oauth: { clientId: CLIENT_ID, redirectUri },
  storage: createSecureStoreAdapter(SecureStore),
  crossTabSync: false,
});

// After AuthSession success — pass full URL when E2EE may include #app_key on web:
await completeExpoOAuthCallback(
  session,
  { clientId: CLIENT_ID, redirectUri },
  {
    code: response.params.code,
    codeVerifier: request.codeVerifier!,
    expectedState: request.state,
    responseState: response.params.state,
    redirectUrlWithFragment: response.url,
  }
);

wireAveSessionToConvex(convex, session);
completeExpoOAuthCallback calls exchangeCode and session.setTokensFromResponse (no sessionStorage / window). Use onExpoAppForegroundRefresh from the same module for background refresh nudges.

Minimal implementation

This example uses Expo for the browser redirect and PKCE request, then uses @ave-id/sdk for Ave’s token exchange and refresh helpers.
import { useEffect, useMemo, useState } from "react";
import { Button, Text, View } from "react-native";
import * as AuthSession from "expo-auth-session";
import * as WebBrowser from "expo-web-browser";
import { exchangeCode, fetchUserInfo, refreshToken } from "@ave-id/sdk";
import { initExpoOAuthBrowserSession, warmUpExpoAuthBrowser } from "@ave-id/sdk/expo-session";

initExpoOAuthBrowserSession(WebBrowser);

const CLIENT_ID = "YOUR_CLIENT_ID";
const ISSUER = "https://aveid.net";

export default function AveLoginScreen() {
  const [tokens, setTokens] = useState<any | null>(null);
  const [profile, setProfile] = useState<any | null>(null);

  const discovery = AuthSession.useAutoDiscovery(ISSUER);

  const redirectUri = useMemo(
    () =>
      AuthSession.makeRedirectUri({
        scheme: "aveexpo",
        path: "oauth/callback",
      }),
    [],
  );

  const [request, response, promptAsync] = AuthSession.useAuthRequest(
    {
      clientId: CLIENT_ID,
      redirectUri,
      responseType: AuthSession.ResponseType.Code,
      scopes: ["openid", "profile", "email", "offline_access"],
      usePKCE: true,
    },
    discovery,
  );

  useEffect(() => {
    return warmUpExpoAuthBrowser(WebBrowser);
  }, []);

  useEffect(() => {
    async function finishLogin() {
      if (!response || response.type !== "success" || !request) return;

      const code = response.params.code;
      if (!code) {
        throw new Error("Missing authorization code");
      }

      if (response.params.state !== request.state) {
        throw new Error("State mismatch");
      }

      const nextTokens = await exchangeCode(
        {
          clientId: CLIENT_ID,
          redirectUri,
          issuer: ISSUER,
        },
        {
          code,
          codeVerifier: request.codeVerifier,
        },
      );

      setTokens(nextTokens);

      const user = await fetchUserInfo(
        { clientId: CLIENT_ID, redirectUri, issuer: ISSUER },
        nextTokens.access_token,
      );

      setProfile(user);
    }

    finishLogin().catch((error) => {
      console.error("Ave login failed", error);
    });
  }, [redirectUri, request, response]);

  async function handleRefresh() {
    if (!tokens?.refresh_token) return;

    const nextTokens = await refreshToken(
      {
        clientId: CLIENT_ID,
        redirectUri,
        issuer: ISSUER,
      },
      {
        refreshToken: tokens.refresh_token,
      },
    );

    setTokens(nextTokens);
  }

  return (
    <View style={{ gap: 12, padding: 24 }}>
      <Button
        title="Continue with Ave"
        disabled={!request}
        onPress={() => promptAsync()}
      />

      {profile ? (
        <Text>Signed in as {profile.email ?? profile.preferred_username ?? profile.sub}</Text>
      ) : null}

      {tokens?.refresh_token ? (
        <Button title="Refresh session" onPress={handleRefresh} />
      ) : null}
    </View>
  );
}

What this flow does

  1. useAuthRequest() creates the authorization request and PKCE verifier/challenge
  2. promptAsync() opens the system browser via Expo’s browser session support
  3. Ave redirects back to your Expo deep link with an authorization code
  4. You exchange that code with exchangeCode(...), passing request.codeVerifier
  5. Ave returns the normal token set: access_token, access_token_jwt, id_token, and optionally refresh_token
Request openid whenever you need id_token. Without openid, Ave will not return an ID token.

Token handling

Ave’s token response has a few fields Expo developers usually care about:
FieldUse
access_tokenBearer token for Ave API endpoints like userinfo
access_token_jwtSigned JWT for Ave resource authorization
id_tokenOIDC identity token for your app and for Convex
refresh_tokenUsed to rotate into a fresh token set when offline_access was granted
Refresh tokens rotate on every use. After a successful refresh, store the new refresh_token immediately and discard the old one.
On Expo native, do not call verifyJwt() in the client. Use the tokens directly for app state, and move JWT signature verification to your backend if you need it.

Convex handoff

If your Expo app talks to Convex, give Convex the Ave id_token:
convex.setAuth(async () => tokens.id_token);
Do not pass access_token_jwt to Convex. Its audience is https://aveid.net, while Convex expects the id_token audience to match your Ave clientId. The full setup is documented in Convex custom auth.

Common pitfalls

Your app scheme or redirect URI is misconfigured. Make sure the scheme in Expo config matches the scheme used in makeRedirectUri(), and register that exact redirect URI in the Ave developer portal.
The authorization code must be exchanged with the exact request.codeVerifier created by useAuthRequest(). Do not generate a second verifier yourself during the callback.
Expo documents that WebBrowser.maybeCompleteAuthSession() should run on web so the popup can finish cleanly. Also make sure the auth flow starts and finishes on the same web origin.
Pass id_token, not access_token_jwt. See Convex custom auth.

Notes on Expo APIs

  • Expo’s AuthSession docs say usePKCE defaults to true, but it is still worth setting explicitly in auth code so the flow is obvious in your app.
  • Expo’s WebBrowser docs recommend openAuthSessionAsync for auth-style browser flows and document warmUpAsync() / coolDownAsync() as Android performance helpers.
  • On web, Expo documents that maybeCompleteAuthSession() is what closes the popup after the redirect page loads.

References

Last modified on May 1, 2026