@smarthivelabs-devs/auth-expo
v1.11.7
Published
SmartHive Auth provider, hooks, and SecureStore integration for React Native / Expo
Readme
@smarthivelabs-devs/auth-expo
SmartHive Auth for React Native and Expo. Provides a provider, hooks, and components with SecureStore-backed token storage.
Supports two sign-in modes — both in the same package, zero config difference:
| Mode | How it works | Good for |
|---|---|---|
| Headless | Call signIn.* directly — no browser, custom UI | Native mobile apps with branded login screens |
| OAuth redirect | login() opens system browser, deep-links back | Social login, SSO, or when you want the hosted UI |
Installation
npx expo install @smarthivelabs-devs/auth-expo expo-auth-session expo-secure-store expo-web-browser# npm / pnpm
npm install @smarthivelabs-devs/auth-expo expo-auth-session expo-secure-store expo-web-browser
pnpm add @smarthivelabs-devs/auth-expo expo-auth-session expo-secure-store expo-web-browserPeer dependencies:
expo-auth-session>=7,expo-secure-store>=14,react>=18,react-native>=0.73
Setup
Create one client and pass it to one provider. The hosted Auth URL and callback path have defaults, so most Expo apps only provide the project ID, publishable key, and app scheme.
// lib/auth.ts
import { createAuthClient } from "@smarthivelabs-devs/auth-expo";
export const auth = createAuthClient({
projectId: process.env.EXPO_PUBLIC_SMARTHIVE_AUTH_PROJECT_ID!,
publishableKey: process.env.EXPO_PUBLIC_SMARTHIVE_AUTH_PUBLISHABLE_KEY!,
scheme: "myapp",
});// app/_layout.tsx
import { AuthProvider } from "@smarthivelabs-devs/auth-expo";
import { auth } from "../lib/auth";
export default function Layout() {
return (
<AuthProvider client={auth}>
<Stack />
</AuthProvider>
);
}That is the complete lifecycle setup. The provider automatically hydrates SecureStore, verifies offline assertions, initializes remotely in the background, refreshes before expiry, refreshes on foreground, handles OAuth deep links, and recovers after request failures. Do not add a second auth store, token timer, AppState refresh handler, JWT decoder, or SecureStore wrapper.
There is no offline-auth setup API. The provider automatically enrolls every online session—including Apple and other social sign-ins—stores and verifies its offline assertion, and silently retries transient enrollment failures. App code only consumes the normal auth snapshot.
Use the same exported client in services outside React:
const response = await auth.fetch("https://api.myapp.com/profile");
const snapshot = auth.getSnapshot();Use useAuth()/useAuthSnapshot() in components and
auth.getSnapshot()/auth.subscribe() outside React. Session transitions are:
loading -> authenticated -> offline -> authenticated
| | |
+-------------+------------+-> unauthenticated (definitive rejection)SecureStore is hydrated before remote initialization. sessionSource
distinguishes verified-offline, whose dedicated assertion passed strict RS256
verification, from pending-revalidation, where a securely stored refresh
credential remains signed in after a transient network or service failure.
Pending revalidation preserves the app shell but never makes an expired access
token usable; the provider retries automatically and returns to online after
connectivity recovers. There is no decode-only fallback. Only a definitive
refresh rejection such as session_expired or invalid_grant clears the local
session.
Offline sign-out clears this device immediately; server revocation cannot occur until a later online session-management action. Revocation elsewhere cannot be discovered while the device is completely offline, so reconnect always revalidates through refresh.
Migration from pre-1.10 Expo apps: remove app-owned SecureStore token writes, refresh intervals, AppState refresh handlers, JWT decoders, and auth mirrors. The provider migrates the old global session record once; users without a valid offline assertion sign in normally when online.
# .env
EXPO_PUBLIC_SMARTHIVE_AUTH_PROJECT_ID=proj_abc123
EXPO_PUBLIC_SMARTHIVE_AUTH_PUBLISHABLE_KEY=pk_prod_abc123
# Optional only when using a custom Auth domain:
EXPO_PUBLIC_SMARTHIVE_AUTH_BASE_URL=https://auth.myapp.comExisting configuration-first usage remains supported:
<SmartHiveAuthProvider projectId="..." publishableKey="..." scheme="myapp">
<Stack />
</SmartHiveAuthProvider>For an immediate reconnect signal while the app remains foregrounded, NetInfo can be injected when it is already part of your app. This is optional—request outcomes and foreground recovery remain authoritative:
import NetInfo from "@react-native-community/netinfo";
export const auth = createAuthClient({
projectId: "...",
publishableKey: "...",
scheme: "myapp",
netInfoSubscribe: (listener) =>
NetInfo.addEventListener((state) =>
listener(state.isConnected === true && state.isInternetReachable !== false)
),
});Headless Sign-in (Custom Login Screen)
No browser, no redirect. Call the method, get tokens. Full control of your UI.
Email + Password
import { useAuth } from "@smarthivelabs-devs/auth-expo";
import { useState } from "react";
import { Button, TextInput, View, Text } from "react-native";
export default function LoginScreen() {
const { signIn } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
async function handleSignIn() {
try {
await signIn.email({ email, password });
// Session is saved automatically — user is now signed in
} catch (e: any) {
setError(e.message);
}
}
return (
<View>
<TextInput value={email} onChangeText={setEmail} placeholder="Email" />
<TextInput value={password} onChangeText={setPassword} placeholder="Password" secureTextEntry />
{error ? <Text>{error}</Text> : null}
<Button title="Sign in" onPress={handleSignIn} />
</View>
);
}Phone OTP
import { useAuth } from "@smarthivelabs-devs/auth-expo";
import { useState } from "react";
import { Button, TextInput, View } from "react-native";
export default function PhoneLoginScreen() {
const { signIn } = useAuth();
const [phone, setPhone] = useState("");
const [code, setCode] = useState("");
const [step, setStep] = useState<"phone" | "code">("phone");
async function sendOtp() {
await signIn.phone.sendOtp({ phoneNumber: phone });
setStep("code");
}
async function verifyOtp() {
await signIn.phone.verify({ phoneNumber: phone, code });
// Signed in — session saved automatically
}
if (step === "phone") {
return (
<View>
<TextInput value={phone} onChangeText={setPhone} placeholder="+1234567890" keyboardType="phone-pad" />
<Button title="Send code" onPress={sendOtp} />
</View>
);
}
return (
<View>
<TextInput value={code} onChangeText={setCode} placeholder="Enter code" keyboardType="number-pad" />
<Button title="Verify" onPress={verifyOtp} />
</View>
);
}Email OTP
const { signIn } = useAuth();
// Step 1 — send the code
await signIn.emailOtp.send({ email: "[email protected]" });
// Step 2 — verify (returns session, user is now signed in)
await signIn.emailOtp.verify({ email: "[email protected]", code: "123456" });Magic Link
const { signIn } = useAuth();
// Sends an email — user clicks the link to sign in (no token returned here)
await signIn.magicLink.send({ email: "[email protected]" });Headless Sign-up
import { useAuth } from "@smarthivelabs-devs/auth-expo";
const { signUp } = useAuth();
const result = await signUp.email({
email: "[email protected]",
password: "secret123",
name: "Jane Doe", // optional
});
if (result.requiresVerification) {
// Email verification required — show "check your inbox" screen
// No session yet, tokens are empty
} else {
// Account created and signed in immediately
}Returning to your app after email verification
By default, the verification link in the email lands on SmartHive's own confirmation page and stops there — the user has to switch back to your app manually. To have it return them automatically (deep link into the app, or a plain web URL), pass redirect (and optionally fallback) to signUp.email or resendVerificationEmail:
import { useAuth, buildRedirectUri } from "@smarthivelabs-devs/auth-expo";
const { signUp } = useAuth();
await signUp.email({
email: "[email protected]",
password: "secret123",
redirect: buildRedirectUri("myapp", "verified"), // e.g. "myapp://verified"
fallback: "https://myapp.com/download", // shown if the app didn't open
});redirect— where SmartHive's confirmation page sends the user after showing a brief "Email verified" state. Prefer a universal/app link (https://myapp.com/verified, intercepted by iOS/Android if the app is installed) over a bare custom scheme where possible — it's more reliable and degrades gracefully to a real web page if the app isn't installed. A custom scheme likebuildRedirectUri("myapp", "verified")works too.fallback— optional, shown as a manual link (e.g. an app-store URL) ifredirectdoesn't seem to have opened the app.- No deep-link parsing is required on your end — verification already completed server-side before the app is reopened, so
Linking's existing URL listener doesn't need new logic for this. The app just needs to be reachable at theredirectURL/scheme you chose. - Omit both to keep today's default (SmartHive's bare confirmation page, no auto-return).
Resend verification email
If the user did not receive the initial verification email — or the link expired — call signUp.resendVerificationEmail to trigger a fresh one. Accepts the same redirect/fallback options as above:
import { useAuth } from "@smarthivelabs-devs/auth-expo";
const { signUp } = useAuth();
// Called from a "Didn't receive the email? Resend" button
await signUp.resendVerificationEmail({ email: "[email protected]" });Throws SmartHiveAuthError with code resend_failed if the server rejects the request.
Social OAuth Sign-in (Google, Apple, GitHub, etc.)
Each social provider uses your project's own credentials — the consent screen shows your app name. Configure credentials in your SmartHive dashboard under Project → OAuth Providers, then call:
import { useAuth } from "@smarthivelabs-devs/auth-expo";
import { Button } from "react-native";
export default function LoginScreen() {
const { signIn } = useAuth();
return (
<>
<Button title="Continue with Google" onPress={() => signIn.social("google")} />
<Button title="Continue with Apple" onPress={() => signIn.social("apple")} />
<Button title="Continue with GitHub" onPress={() => signIn.social("github")} />
</>
);
}signIn.social() calls Linking.openURL() to open the provider's consent screen in the system browser. When the user approves, the provider redirects back to your app's redirectUri deep link with ?access_token=...&refresh_token=.... The SmartHiveAuthProvider deep link listener picks this up automatically and saves the session — no extra setup needed.
Supported providers:
google · apple · github · facebook · twitter · linkedin · microsoft · discord · spotify · twitch · reddit · gitlab · slack · notion · zoom · figma
Deep link required — make sure
redirectUriis set to your app scheme (e.g.myapp://auth/callback) and your scheme is registered inapp.json. See the Deep Link Setup section below.
OAuth Redirect Sign-in (SmartHive hosted page)
The original PKCE flow — redirects to the SmartHive hosted login page and back. Use it for SSO or when you want the hosted login UI.
import { useAuth } from "@smarthivelabs-devs/auth-expo";
import { Button } from "react-native";
export default function LoginScreen() {
const { login } = useAuth();
return <Button title="Sign in with SmartHive" onPress={() => login()} />;
}Deep Link Setup (required for OAuth redirect only)
If you use login(), register a custom scheme in app.json so the browser can redirect back:
{
"expo": {
"scheme": "myapp",
"android": {
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [{ "scheme": "myapp" }],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}Rebuild after changing app.json:
npx expo prebuildNo extra setup needed for headless sign-in — it works without a deep link, unless you also want the post-verification return flow described in Returning to your app after email verification, which reuses this same scheme.
Sign-out
const { logout } = useAuth();
// Clears SecureStore + invalidates session on the server
await logout();Hooks
useAuth()
Returns the full auth context.
const {
session, // AuthSession | null
isLoaded, // true once initial SecureStore read is done
isSignedIn, // boolean
login, // OAuth redirect sign-in (PKCE, SmartHive hosted page)
logout, // sign out
signIn, // headless + social sign-in methods
signUp, // headless sign-up (email, resendVerificationEmail)
refreshSession, // force a token refresh
authFetch, // authenticated fetch wrapper
getAuthorizationHeader, // () => Promise<{ authorization: string }>
} = useAuth();
// Social sign-in is under signIn.social:
await signIn.social("google");
await signIn.social("apple");
await signIn.social("github");useSession()
const session = useSession(); // AuthSession | null
// session.accessToken, session.refreshToken, session.expiresAt, session.useruseUser()
const user = useUser(); // unknown | nulluseIsLoaded()
true once the initial SecureStore check is complete. Use this to avoid a flash of unauthenticated state on startup.
const isLoaded = useIsLoaded();
if (!isLoaded) return <SplashScreen />;useIsSignedIn()
Returns true (signed in), false (signed out), or null (still loading).
const isSignedIn = useIsSignedIn();
useEffect(() => {
if (isSignedIn === false) router.replace("/login");
}, [isSignedIn]);useAuthFetch()
Authenticated fetch wrapper. Bearer token is injected automatically and refreshed transparently when near expiry.
const authFetch = useAuthFetch();
const res = await authFetch("https://api.myapp.com/protected");useAuthorizationHeader()
Resolves to { authorization: "Bearer <token>" }. Useful for GraphQL clients or custom SDK setup.
const getAuthorizationHeader = useAuthorizationHeader();
const headers = await getAuthorizationHeader();Render Helpers
import { SignedIn, SignedOut, AuthLoading } from "@smarthivelabs-devs/auth-expo";
// Shown only when loaded + authenticated
<SignedIn><Dashboard /></SignedIn>
// Shown only when loaded + not authenticated
<SignedOut><LoginScreen /></SignedOut>
// Shown while the initial SecureStore check is running
<AuthLoading><ActivityIndicator /></AuthLoading>Expo Router Integration
app/
├── _layout.tsx ← SmartHiveAuthProvider here
├── index.tsx ← SignedIn / SignedOut routing
├── login.tsx ← your custom login screen using signIn.*
└── (protected)/
└── dashboard.tsx// app/_layout.tsx
import { Stack } from "expo-router";
import { SmartHiveAuthProvider, buildRedirectUri } from "@smarthivelabs-devs/auth-expo";
export default function Layout() {
return (
<SmartHiveAuthProvider
projectId={process.env.EXPO_PUBLIC_SMARTHIVE_AUTH_PROJECT_ID!}
publishableKey={process.env.EXPO_PUBLIC_SMARTHIVE_AUTH_PUBLISHABLE_KEY!}
baseUrl={process.env.EXPO_PUBLIC_SMARTHIVE_AUTH_BASE_URL!}
redirectUri={buildRedirectUri("myapp")}
>
<Stack />
</SmartHiveAuthProvider>
);
}// app/index.tsx
import { SignedIn, SignedOut, AuthLoading } from "@smarthivelabs-devs/auth-expo";
import { Redirect } from "expo-router";
import { ActivityIndicator } from "react-native";
export default function Index() {
return (
<>
<AuthLoading><ActivityIndicator /></AuthLoading>
<SignedIn><Redirect href="/dashboard" /></SignedIn>
<SignedOut><Redirect href="/login" /></SignedOut>
</>
);
}// app/login.tsx — custom screen, no browser redirect
import { useAuth } from "@smarthivelabs-devs/auth-expo";
import { useState } from "react";
import { Button, TextInput, View, Text, StyleSheet } from "react-native";
export default function LoginScreen() {
const { signIn } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleSignIn() {
setLoading(true);
setError("");
try {
await signIn.email({ email, password });
} catch (e: any) {
setError(e.message ?? "Sign in failed.");
} finally {
setLoading(false);
}
}
return (
<View style={styles.container}>
<TextInput style={styles.input} value={email} onChangeText={setEmail} placeholder="Email" autoCapitalize="none" />
<TextInput style={styles.input} value={password} onChangeText={setPassword} placeholder="Password" secureTextEntry />
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button title={loading ? "Signing in…" : "Sign in"} onPress={handleSignIn} disabled={loading} />
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: "center", padding: 24 },
input: { borderWidth: 1, borderColor: "#ccc", borderRadius: 8, padding: 12, marginBottom: 12 },
error: { color: "red", marginBottom: 12 },
});Token Storage
All tokens are stored in expo-secure-store:
- iOS: Keychain Services
- Android: Android Keystore (AES encryption)
PKCE verifier and state (used during OAuth redirect flow) are also stored in SecureStore and deleted after the code exchange completes.
Provider Props
| Prop | Type | Required | Description |
|---|---|---|---|
| projectId | string | Yes | Your SmartHive project ID |
| publishableKey | string | Yes | Your publishable key (pk_prod_*) |
| baseUrl | string | Yes | URL of your SmartHive Auth service |
| redirectUri | string | Yes | Deep link callback URI — used only for OAuth redirect flow |
| authDomain | string | No | Custom branded auth domain |
| children | ReactNode | Yes | Your app tree |
TypeScript Types
import type {
SmartHiveExpoConfig,
SmartHiveAuthProviderProps,
} from "@smarthivelabs-devs/auth-expo";
import type {
AuthSession,
HeadlessClient,
HeadlessSignInResult,
HeadlessSignUpResult,
SmartHiveAuthClient,
} from "@smarthivelabs-devs/auth-sdk";Low-level: initExpoAuth
Direct client without the provider (advanced use):
import { initExpoAuth, buildRedirectUri } from "@smarthivelabs-devs/auth-expo";
const client = initExpoAuth({
projectId: "proj_abc123",
publishableKey: "pk_prod_abc123",
baseUrl: "https://auth.myapp.com",
redirectUri: buildRedirectUri("myapp"),
});
await client.initialize();
// Headless sign-in
const session = await client.headless.signIn.email({ email, password });
// OAuth redirect
await client.login();Social Auth Proxy (White-label Domain)
By default, when a user taps "Sign in with Google", the iOS system prompt shows:
"YourApp wants to use authcore.smarthivelabs.dev to sign in"
And the Google consent screen shows:
"to continue to smarthivelabs.dev"
To show your own domain on both prompts, add two thin proxy routes to your backend and set socialProxyUrl in the provider config. SmartHive Auth does all the OAuth work — your domain just acts as the visible entry point.
Step 1 — Add proxy routes to your backend
// src/routes/socialProxyRoutes.ts
import express from "express";
const router = express.Router();
const SMARTHIVE_BASE = process.env.SMARTHIVE_AUTH_BASE_URL!; // https://authcore.smarthivelabs.dev
const SMARTHIVE_PID = process.env.SMARTHIVE_AUTH_PROJECT_ID!;
const APP_BASE_URL = process.env.APP_BASE_URL!; // https://api.yourapp.com
// Initiation — app calls this, proxy forwards to SmartHive
router.get("/:provider", async (req, res) => {
const target = new URL(`${SMARTHIVE_BASE}/api/auth/social/${req.params.provider}`);
target.searchParams.set("project_id", SMARTHIVE_PID);
if (req.query.redirect_uri) target.searchParams.set("redirect_uri", req.query.redirect_uri as string);
target.searchParams.set("proxy_callback", `${APP_BASE_URL}/api/auth/social/${req.params.provider}/callback`);
res.redirect(target.toString());
});
// Callback — Google/Apple calls this, proxy forwards to SmartHive
router.get("/:provider/callback", async (req, res) => {
const target = new URL(`${SMARTHIVE_BASE}/api/auth/social/${req.params.provider}/callback`);
for (const [k, v] of Object.entries(req.query)) target.searchParams.set(k, v as string);
res.redirect(target.toString());
});
app.use("/api/auth/social", router);Required env vars on your backend:
SMARTHIVE_AUTH_BASE_URL=https://authcore.smarthivelabs.dev
APP_BASE_URL=https://api.yourapp.comStep 2 — Register your callback URL with each provider
In your Google Cloud Console (or Apple/GitHub/etc. developer console), register:
https://api.yourapp.com/api/auth/social/google/callbackinstead of the SmartHive URL. Replace google with each provider you support.
Step 3 — Set socialProxyUrl in your app
<SmartHiveAuthProvider
publishableKey="pk_prod_..."
projectId="your-project-id"
baseUrl="https://authcore.smarthivelabs.dev"
redirectUri="myapp://auth/callback"
socialProxyUrl="https://api.yourapp.com" // ← add this
>
<App />
</SmartHiveAuthProvider>That's it. signIn.social("google") now opens api.yourapp.com/api/auth/social/google, iOS shows your domain, and Google shows "to continue to yourapp.com".
Backend Integration
Most apps need a backend that the Expo app calls for data. Here is the complete flow for how the Expo SDK, the developer's backend, and the SmartHive server SDK work together.
Architecture overview
Expo App Developer Backend SmartHive Auth
──────────────────────────────────────────────────────────────────────────────
1. signIn.email / signIn.social ──── (direct to SmartHive) ────► headless endpoint
◄── access_token + refresh_token
SecureStore ◄── tokens saved
2. authFetch("https://api.myapp.com/me") ──► requireAuth() ──► JWKS endpoint
(adds Authorization: Bearer <token>) verifies JWT (once per key rotation)
◄── req.auth.userId
your API logic runs
◄── JSON responseNo tokens are sent to your backend — only the short-lived access token (JWT) in the Authorization header. Your backend never sees or stores the refresh token.
Step 1 — Expo app calls your backend
Every method from useAuthFetch() or authFetch automatically adds the Authorization: Bearer <token> header and transparently refreshes the access token when it is near expiry.
// In your Expo screen
import { useAuthFetch } from "@smarthivelabs-devs/auth-expo";
const authFetch = useAuthFetch();
const res = await authFetch("https://api.myapp.com/profile");
const data = await res.json();Or using client.fetch directly:
const { client } = useAuth();
const res = await client.fetch("https://api.myapp.com/orders");Step 2 — Backend verifies the JWT
Install the server SDK on your backend:
npm install @smarthivelabs-devs/auth-server// Express — src/middleware/auth.ts
import { requireAuth } from "@smarthivelabs-devs/auth-server";
export const protect = requireAuth({
issuer: process.env.SMARTHIVE_AUTH_ISSUER!, // e.g. https://authcore.smarthivelabs.dev
projectId: process.env.SMARTHIVE_AUTH_PROJECT_ID!, // optional — rejects tokens from other projects
});SMARTHIVE_AUTH_ISSUER=https://authcore.smarthivelabs.dev
SMARTHIVE_AUTH_PROJECT_ID=proj_abc123// Express — src/routes/profile.ts
import { protect } from "../middleware/auth";
router.get("/profile", protect, (req, res) => {
// req.auth is typed as AuthContext
res.json({ userId: req.auth!.userId, email: req.auth!.email });
});Step 3 — Sign-up flow
Sign-up goes directly to SmartHive Auth — your backend does not need a sign-up endpoint.
// Expo — sign-up screen
const { signUp } = useAuth();
const result = await signUp.email({
email: "[email protected]",
password: "secret123",
name: "Jane Doe",
});
if (result.requiresVerification) {
// Show "check your inbox" screen
// No session yet — user must verify email before signing in
} else {
// Session saved automatically — user is now signed in
// Next authFetch call to your backend will include a valid token
}Once signed in, calls to authFetch on your backend will immediately work — no extra step needed.
Step 4 — Admin operations (optional)
If your backend needs to look up user details, ban a user, or list sessions, use createAdminClient from the server SDK with your shai_* key from the dashboard.
import { requireAuth, createAdminClient } from "@smarthivelabs-devs/auth-server";
const smarthive = createAdminClient({
secretKey: process.env.SMARTHIVE_AUTH_ADMIN_KEY!, // shai_*
baseUrl: process.env.SMARTHIVE_AUTH_ISSUER!,
});
// After verifying the JWT, look up the full user record
router.get("/profile", protect, async (req, res) => {
const { user } = await smarthive.users.get(req.auth!.userId);
res.json(user);
});Full sign-in flow reference
| Step | Who | What happens |
|---|---|---|
| 1 | Expo app | signIn.email() or signIn.social() — tokens returned from SmartHive |
| 2 | Expo app | Tokens stored in SecureStore (never leaves the device in plaintext) |
| 3 | Expo app | authFetch(backendUrl) — sends Authorization: Bearer <access_token> |
| 4 | Backend | requireAuth() fetches SmartHive JWKS once, verifies JWT signature + expiry |
| 5 | Backend | req.auth.userId is available — your business logic runs |
| 6 | Backend | (optional) smarthive.users.get(userId) for full user profile |
| Token refresh | Expo SDK | When access token is ≤ 30 s from expiry, automatically refreshed before the next authFetch — your backend never sees an expired token |
License
MIT © SmartHive Labs
