- Building React Native auth from scratch routinely takes 60–120 hours and ships subtle bugs (the Apple Sign In
fullNametrap is the classic). - The real surface area is seven traps: AsyncStorage encryption, refresh-token mutex, three OAuth deep-link code paths,
fullNamepersistence, mandatory account deletion, magic-link rate limiting, token hashing at rest. - A vetted template covers all seven for $99–$499. Break-even is ~3 hours of saved work.
- Build it yourself only if you have enterprise IdP (SAML/Okta), HIPAA/FIDO2 constraints, or your company is the auth product.
Every React Native auth implementation starts with "it's just signInWithPassword" and ends 75 hours later debugging refresh-token rotation. This post is the field guide I wish I had before I built mobile auth from scratch three times.
The seven traps
If you build auth from scratch in React Native, here's what bites you in production. None of these are in the happy-path tutorial.
1. AsyncStorage is not encrypted
// 🚫 wrong
import AsyncStorage from "@react-native-async-storage/async-storage";
await AsyncStorage.setItem("access_token", token);
// ✅ right
import * as SecureStore from "expo-secure-store";
await SecureStore.setItemAsync("access_token", token);
On iOS, SecureStore uses Keychain. On Android, EncryptedSharedPreferences. AsyncStorage is a plaintext SQLite blob.
2. Refresh-token rotation needs a mutex
Supabase rotates refresh tokens on every use. Two concurrent API calls at the moment of expiry will both try to refresh, and one of them invalidates the other's freshly-minted token. You need a single-flight mutex around refresh:
let refreshPromise: Promise<Session> | null = null;
async function getValidSession() {
if (refreshPromise) return refreshPromise;
refreshPromise = supabase.auth.refreshSession()
.finally(() => { refreshPromise = null; });
return refreshPromise;
}
3. OAuth deep links are three code paths
-
iOS native build:
ASWebAuthenticationSessionviaexpo-auth-session -
Android native build: Chrome Custom Tabs via
expo-auth-session - Expo Go: a polyfill that opens a system browser
Each one returns control through a different URL scheme. Universal Links on iOS, intent filters on Android, and an exp:// URL in Expo Go. Get any wrong and the user lands on a white screen.
4. Apple Sign In fullName only returns on first sign-in
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
// credential.fullName is null on EVERY subsequent sign-in.
// You MUST persist it the first time, server-side.
This is the bug I've shipped on three separate projects.
5. Account deletion is mandatory
App Store guideline 5.1.1(v): you must offer in-app account deletion. That means a confirmation screen, a server endpoint that revokes all sessions, and a way to handle a GDPR data-export request. Skip it, fail review.
6. Rate limit your magic-link endpoint
Without per-email and per-IP throttling, anyone can blow up your transactional email bill. The minimum pattern:
const RATE_LIMIT_WINDOW_MS = 60_000;
const RATE_LIMIT_MAX = 3;
const recent = await supabaseServer
.from("magic_link_tokens")
.select("created_at", { count: "exact" })
.eq("email", email)
.gte("created_at", new Date(Date.now() - RATE_LIMIT_WINDOW_MS).toISOString());
if ((recent.count ?? 0) >= RATE_LIMIT_MAX) {
return Response.json({ error: "rate_limited" }, { status: 429 });
}
7. Hash your tokens at rest
const token = crypto.randomBytes(32).toString("hex");
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
// store tokenHash, email the user `token`
If your DB ever leaks, the rows are worthless.
The cost, with numbers
| Workstream | Hours from scratch | Hours with a template |
|---|---|---|
| Email + password screens | 6h | 0h |
| Google OAuth (iOS + Android + Expo Go) | 8h | 1h |
| Apple Sign In + name persistence | 6h | 1h |
| Magic link + email delivery | 10h | 1h |
| Secure token storage + refresh mutex | 8h | 0h |
| Biometric unlock | 5h | 1h |
| Password reset + account deletion | 6h | 0h |
| Schema + RLS policies | 8h | 0h |
| Rate limiting + email DKIM | 6h | 1h |
| Testing | 12h | 4h |
| Total | 75h | 9h |
At $75/hour, that's $5,625 vs $675. Templates range from $99 to $499. The math isn't subtle.
What a real template ships
Production-grade React Native templates wire all seven traps above and ship them as default behavior. The baseline:
-
expo-secure-storetoken storage - Refresh-token mutex
- Google + Apple + magic link + email/password
- Apple Sign In
fullNamepersistence - Account deletion endpoint
- Rate-limited magic-link API with SHA-256 token hashing
- Supabase migrations with RLS policies on every user-scoped table
- Biometric unlock via
expo-local-authentication
If a template you're evaluating doesn't ship all of these, you're getting a UI kit with a database attached — not auth. Ask to see the migrations and the magic-link route before you spend any money.
When to build it yourself
- Enterprise IdP (SAML / Okta) integration
- Compliance constraints (HIPAA-eligible IdPs, FIDO2 step-up)
- You're literally building an auth product
Otherwise, you're paying a tax with zero offsetting return. The hours you spend rebuilding OAuth callbacks are hours your competitors are spending on features users actually see.
Further reading
For the longer breakdown — the actual template I use, the migrations, and the full magic-link route — see Applighter.
What's the auth gotcha that ate the most hours on your last React Native project? Drop it in the comments — I'm collecting the failure modes that don't make it into the docs for a follow-up.
Top comments (0)