Universal & React Native
@identsphere/core has no DOM and no dependencies, so it drops straight into a
React Native / Tamagui app, a non-React framework, or an edge worker. This guide
walks through the native story end to end: bearer mode, secure token storage,
building your own sign-in and MFA-challenge UI, and a route guard — all on top of
the @identsphere/core API.
Install
npm install @identsphere/core
# plus, on Expo, a secure store for the access token:
npx expo install expo-secure-store
Create the client (bearer mode)
Native apps use bearer mode: the client sends
Authorization: Bearer <access_token>, reading the token from a TokenStore
you inject. Back that store with secure storage so the access token survives app
restarts.
// auth.ts
import { IdentSphereClient, createKeyValueTokenStore } from "@identsphere/core";
import * as SecureStore from "expo-secure-store";
const tokenStore = createKeyValueTokenStore(SecureStore, "identsphere.at");
export const auth = new IdentSphereClient({
baseUrl: "https://auth.example.com",
mode: "bearer",
tokenStore,
onUnauthenticated: () => {
// Wire this to your navigator — the core never touches navigation itself.
router.replace("/sign-in");
},
});
createKeyValueTokenStore accepts anything shaped like
{ getItem, setItem, removeItem } (sync or async), so expo-secure-store,
AsyncStorage, and react-native-keychain all work as drop-ins. The
refresh token is never exposed to JS — it stays an httpOnly identsphere_rt
cookie by design.
Refresh and the cookie jar
By default refresh() carries the httpOnly identsphere_rt cookie via the
platform's cookie jar. Browsers and React Native (bare RN and Expo) have one
automatically, so refresh() — and the silent refresh-and-retry on a 401 —
works out of the box on those platforms after a plain password / email-OTP
login.
Bare Node and Cloudflare Workers do not have a persistent cookie jar, so a
plain password login there leaves refresh() with no identsphere_rt cookie to
send.
The body-mode refresh path fixes this. When the client is holding a refresh
token in its TokenStore, refresh() sends that token in the request body
(instead of relying on the cookie) and persists the rotated token it gets back.
The client acquires a refresh token this way in two situations:
- After
exchangeOAuthCode()— the OAuth exchange returns both the access and refresh tokens in its body, and the client stores them. - After any prior body-mode refresh — each rotation hands back a fresh refresh token, which the client persists.
So a cookie-less runtime (Node / Workers / edge) does have a working refresh
path once it has authenticated via the OAuth Authorization-Code flow, because
that flow seeds the TokenStore with a refresh token.
Body mode only kicks in for mode: "bearer" clients whose TokenStore
implements getRefreshToken / setRefreshToken — MemoryTokenStore and
createKeyValueTokenStore both do. A plain password login on a cookie-less
runtime still has nothing to seed the store, so it remains cookie-dependent;
there, either authenticate via OAuth or inject a cookie-jar-aware fetch
wrapper via config.fetch (for example, one built on a tough-cookie jar in
Node) so the refresh cookie is stored and re-sent.
Bootstrap on app start
Resolve the initial auth state once when the app launches:
import { auth } from "./auth";
async function boot() {
const session = await auth.bootstrap();
// session is null if there's no valid token/cookie; otherwise it's the
// current SessionResponse. The client's state is now "authenticated" or
// "unauthenticated".
}
A sign-in screen
Drive your own UI against login(). Because login() returns a discriminated
union, the MFA branch is just an if:
import { useState } from "react";
import { YStack, Input, Button, Paragraph } from "tamagui";
import { IdentSphereError } from "@identsphere/core";
import { auth } from "./auth";
export function SignIn({ onMfa, onDone }: {
onMfa: (mfaToken: string) => void;
onDone: () => void;
}) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit() {
setBusy(true);
setError(null);
try {
const result = await auth.login({ email, password });
if (result.status === "mfa_required") {
onMfa(result.mfa_token); // valid for result.mfa_token_expires_in seconds
} else {
onDone(); // result.status === "success" — client is now authenticated
}
} catch (e) {
if (e instanceof IdentSphereError) {
setError(e.isRateLimited ? "Too many attempts. Try again later." : "Sign-in failed.");
} else {
setError("Something went wrong.");
}
} finally {
setBusy(false);
}
}
return (
<YStack gap="$3" padding="$4">
<Input placeholder="Email" autoCapitalize="none" keyboardType="email-address"
value={email} onChangeText={setEmail} />
<Input placeholder="Password" secureTextEntry value={password} onChangeText={setPassword} />
<Button disabled={busy} onPress={submit}>Sign in</Button>
{error && <Paragraph color="$red10">{error}</Paragraph>}
</YStack>
);
}
An MFA-challenge screen
When login() returns mfa_required, hand the mfa_token to a second screen
and resolve it with completeMfaChallenge(). Exactly one of code (TOTP) or
recovery_code must be supplied:
import { useState } from "react";
import { YStack, Input, Button, Paragraph } from "tamagui";
import { IdentSphereError } from "@identsphere/core";
import { auth } from "./auth";
export function MfaChallenge({ mfaToken, onDone }: {
mfaToken: string;
onDone: () => void;
}) {
const [code, setCode] = useState("");
const [error, setError] = useState<string | null>(null);
async function submit() {
setError(null);
try {
await auth.completeMfaChallenge({ mfa_token: mfaToken, code });
onDone(); // client is now authenticated
} catch (e) {
setError(e instanceof IdentSphereError ? "Invalid code." : "Something went wrong.");
}
}
return (
<YStack gap="$3" padding="$4">
<Input placeholder="6-digit code" keyboardType="number-pad"
value={code} onChangeText={setCode} />
<Button onPress={submit}>Verify</Button>
{error && <Paragraph color="$red10">{error}</Paragraph>}
</YStack>
);
}
To accept a recovery code instead, swap the body for
{ mfa_token: mfaToken, recovery_code: enteredCode }.
OAuth social login (PKCE)
Native apps can't read IdentSphere's first-party cookies — they live on another origin — so social sign-in uses the OAuth 2.1 Authorization Code flow with PKCE. The shape is:
startOAuth()mints a PKCE pair and returns the IdentSphereauthorizeUrl+ acodeVerifier. You open the URL in a system browser / WebView and persist the verifier.- The user authorizes with the provider; IdentSphere mints a single-use,
PKCE-bound authorization code (120-second TTL) and redirects the browser to
your
redirectTo?code=…. Tokens never transit the URL — only the code. - Your deep-link handler reads
?code=…and callsexchangeOAuthCode({ code, codeVerifier }). The server constant-time-verifiesSHA-256(verifier)against the challenge, burns the code, and returns the access + refresh tokens, which the client stores. State hydrates and you're signed in.
This flow only works when the IdentSphere deployment allowlists your callback
URL in IDENTSPHERE_OAUTH_RP_REDIRECTS and your origin in CORS_ALLOW_ORIGINS.
See Cross-origin OAuth.
A custom scheme like myapp://auth/callback is a valid allowlist entry.
import * as WebBrowser from "expo-web-browser";
import * as SecureStore from "expo-secure-store";
import * as Linking from "expo-linking";
import { Button } from "tamagui";
import { IdentSphereError, type OAuthProvider } from "@identsphere/core";
import { auth } from "./auth";
const REDIRECT_TO = "myapp://auth/callback";
const VERIFIER_KEY = "identsphere.oauth.verifier";
export function SocialSignIn({ onDone }: { onDone: () => void }) {
async function signInWith(provider: OAuthProvider) {
// 1. Mint the PKCE pair + authorize URL.
const { authorizeUrl, codeVerifier } = auth.startOAuth({
provider,
redirectTo: REDIRECT_TO,
});
// Persist the verifier — we need it after the browser round-trip.
await SecureStore.setItemAsync(VERIFIER_KEY, codeVerifier);
// 2. Open the provider's consent screen and wait for the deep-link back.
const result = await WebBrowser.openAuthSessionAsync(authorizeUrl, REDIRECT_TO);
if (result.type !== "success") return; // user dismissed
// 3. Pull ?code=… off the callback URL and exchange it for tokens.
const { queryParams } = Linking.parse(result.url);
const code = queryParams?.code as string | undefined;
const oauthError = queryParams?.oauth_error as string | undefined;
if (oauthError) throw new Error(oauthError); // e.g. provider "access_denied"
if (!code) return;
const verifier = await SecureStore.getItemAsync(VERIFIER_KEY);
if (!verifier) return;
try {
await auth.exchangeOAuthCode({ code, codeVerifier: verifier });
onDone(); // client is now authenticated
} catch (e) {
if (e instanceof IdentSphereError) {
// A bad/expired/reused code surfaces as an auth error.
}
throw e;
} finally {
await SecureStore.deleteItemAsync(VERIFIER_KEY);
}
}
return (
<>
<Button onPress={() => signInWith("google")}>Continue with Google</Button>
<Button onPress={() => signInWith("github")}>Continue with GitHub</Button>
</>
);
}
After exchangeOAuthCode() resolves, the client holds both the access and
refresh tokens in its TokenStore, so refresh() — and the silent
refresh-and-retry on a 401 — works without a cookie jar. That is the one path
that gives cookie-less runtimes (Node / Workers / edge) a working refresh, as
the cookie-jar note explains.
A route guard with subscribe() / getState()
The client publishes a coarse AuthState you can read synchronously with
getState() and observe with subscribe(). That is all you need for a route
guard — no React-specific hook required.
import { useEffect, useState } from "react";
import { auth } from "./auth";
import type { AuthState } from "@identsphere/core";
function useAuthState(): AuthState {
// subscribe() invokes the listener immediately with the current state,
// then on every transition, and returns an unsubscribe fn.
const [state, setState] = useState<AuthState>(() => auth.getState());
useEffect(() => auth.subscribe(setState), []);
return state;
}
export function RequireAuth({ children }: { children: React.ReactNode }) {
const state = useAuthState();
if (state.status === "unknown") return <Splash />; // still booting
if (state.status !== "authenticated") return <Redirect to="/sign-in" />;
return <>{children}</>;
}
getState() returns { status: "unknown" } until the first login() /
getSession() / bootstrap() resolves, so guarding on "unknown" lets you show
a splash screen while bootstrap() runs at startup.
Calling protected endpoints
Once authenticated, use the typed methods or the
request() escape hatch — both attach the bearer token, apply CSRF rules, and
run the single-flight refresh-and-retry engine:
const me = await auth.request("GET", "/v1/users/me");
Non-React universal runtimes
The same client works without React. In Node, Deno, Bun, or Cloudflare Workers,
construct the client the same way (mode: "bearer", a MemoryTokenStore or your
own TokenStore), and inject fetch only if your runtime lacks a global one
(Node <18):
import { IdentSphereClient } from "@identsphere/core";
const auth = new IdentSphereClient({
baseUrl: "https://auth.example.com",
mode: "bearer",
});
Re-read the cookie-jar warning above before
relying on refresh() in bare Node or Workers.
See also
@identsphere/coreAPI reference — every method, type, and config option.- SDK overview — which package to use.