ConnectQuickstart: React Native

Quickstart: React Native

This guide walks through a React Native integration using expo-auth-session for the OAuth flow. React Native apps are public clients — they do not use a client_secret and rely entirely on PKCE for security.

Prerequisites: Expo SDK 51+, an OAuth client registered as Mobile app at developers.platform.loop.health (see the main quickstart).

Install dependencies

npx expo install expo-auth-session expo-crypto expo-web-browser

Register your redirect URI

In the developer portal, add your app’s redirect URI. For Expo development:

exp://127.0.0.1:8081/--/auth/callback

For production builds, use a custom scheme registered in app.json:

{
  "expo": {
    "scheme": "myloopapp"
  }
}

This gives you: myloopapp://auth/callback

Full example — ConnectWithLoop.tsx

import * as AuthSession from "expo-auth-session";
import * as Crypto from "expo-crypto";
import * as WebBrowser from "expo-web-browser";
import { useEffect, useState } from "react";
import { Button, Text, View } from "react-native";
 
WebBrowser.maybeCompleteAuthSession();
 
const DISCOVERY = {
  authorizationEndpoint: "https://identity.platform.loop.health/v1/oauth/authorize",
  tokenEndpoint: "https://identity.platform.loop.health/v1/oauth/token",
  revocationEndpoint: "https://identity.platform.loop.health/v1/oauth/revoke",
};
 
const CLIENT_ID = "client_01HXY...";
const SCOPES = ["openid", "profile", "read:biomarkers"];
 
export function ConnectWithLoop() {
  const redirectUri = AuthSession.makeRedirectUri({ path: "auth/callback" });
  const [tokens, setTokens] = useState<AuthSession.TokenResponse | null>(null);
  const [biomarkers, setBiomarkers] = useState(null);
 
  const [request, response, promptAsync] = AuthSession.useAuthRequest(
    {
      clientId: CLIENT_ID,
      scopes: SCOPES,
      redirectUri,
      usePKCE: true,
      responseType: AuthSession.ResponseType.Code,
    },
    DISCOVERY,
  );
 
  useEffect(() => {
    if (response?.type !== "success") return;
 
    const { code } = response.params;
 
    AuthSession.exchangeCodeAsync(
      {
        clientId: CLIENT_ID,
        code,
        redirectUri,
        extraParams: { code_verifier: request?.codeVerifier ?? "" },
      },
      DISCOVERY,
    ).then((tokenResponse) => {
      setTokens(tokenResponse);
    });
  }, [response]);
 
  async function fetchBiomarkers() {
    if (!tokens) return;
 
    const res = await fetch(
      "https://clinical.platform.loop.health/v1/biomarkers/me",
      { headers: { Authorization: `Bearer ${tokens.accessToken}` } },
    );
 
    if (res.status === 401) {
      // Access token expired — refresh it
      const refreshed = await tokens.refreshAsync(
        { clientId: CLIENT_ID },
        DISCOVERY,
      );
      setTokens(refreshed);
      // Retry with new token
      const retry = await fetch(
        "https://clinical.platform.loop.health/v1/biomarkers/me",
        { headers: { Authorization: `Bearer ${refreshed.accessToken}` } },
      );
      setBiomarkers(await retry.json());
      return;
    }
 
    setBiomarkers(await res.json());
  }
 
  return (
    <View style={{ padding: 24, gap: 16 }}>
      {!tokens ? (
        <Button
          title="Connect with Loop"
          disabled={!request}
          onPress={() => promptAsync()}
        />
      ) : (
        <>
          <Text>Connected! Scopes: {tokens.scope}</Text>
          <Button title="Fetch Biomarkers" onPress={fetchBiomarkers} />
          {biomarkers && (
            <Text>{JSON.stringify(biomarkers, null, 2)}</Text>
          )}
        </>
      )}
    </View>
  );
}

Secure token storage

Never store tokens in AsyncStorage unencrypted. Use expo-secure-store:

import * as SecureStore from "expo-secure-store";
 
async function saveTokens(tokens: AuthSession.TokenResponse) {
  await SecureStore.setItemAsync(
    "loop_tokens",
    JSON.stringify({
      accessToken: tokens.accessToken,
      refreshToken: tokens.refreshToken,
      expiresIn: tokens.expiresIn,
      issuedAt: tokens.issuedAt,
    }),
  );
}
 
async function loadTokens(): Promise<AuthSession.TokenResponse | null> {
  const raw = await SecureStore.getItemAsync("loop_tokens");
  if (!raw) return null;
  return new AuthSession.TokenResponse(JSON.parse(raw));
}

For standalone builds, configure the custom URL scheme in app.json:

{
  "expo": {
    "scheme": "myloopapp",
    "ios": {
      "bundleIdentifier": "com.example.myloopapp"
    },
    "android": {
      "package": "com.example.myloopapp",
      "intentFilters": [
        {
          "action": "VIEW",
          "data": [{ "scheme": "myloopapp", "host": "auth", "pathPrefix": "/callback" }],
          "category": ["BROWSABLE", "DEFAULT"]
        }
      ]
    }
  }
}

Register myloopapp://auth/callback as a redirect URI in the Loop developer portal.

Key differences from web apps

AspectWeb (confidential)React Native (public)
Client secretSent in token exchangeNever used
PKCERequiredRequired
Token storageServer-side session / encrypted DBexpo-secure-store
Redirect URIhttps:// URLCustom scheme (myloopapp://)
RefreshServer-side POST /tokenTokenResponse.refreshAsync()

Next steps

  • Authorization flow — understand the full state machine and error paths.
  • Scopes — choose the right scopes for your integration.
  • Security — PKCE details, redirect URI rules, what to never log.