La autenticación es, sin duda, uno de esos puntos donde aparece toda la fricción al pasar de un prototipo a una app de producción. Y mira, lo digo con cariño: he visto equipos perder semanas enteras intentando "arreglar" el login cuando lo que realmente necesitaban era apoyarse en herramientas maduras. En 2026, el ecosistema de React Native ya no te obliga a reinventar criptografía, sesiones ni OAuth desde cero.
Esta guía cubre, con código real, cómo integrar autenticación segura en una app Expo + Expo Router usando Clerk, Supabase Auth, expo-auth-session, almacenamiento seguro con expo-secure-store, biometría con expo-local-authentication y, sobre todo, el patrón nuevo de rutas protegidas con Stack.Protected que llegó con Expo Router v5 (SDK 53+).
Si vienes de la guía de Navegación en React Native 2026, este artículo es la continuación lógica: ya enrutas pantallas, ahora toca decidir quién puede entrar a cada una.
Panorama de opciones de autenticación en 2026
Antes de escribir una sola línea, vale la pena entender qué te ofrece cada proveedor y cuándo conviene cada uno. Estas son las cinco opciones que dominan el ecosistema React Native ahora mismo:
| Proveedor | Mejor para | OAuth social | Passkeys | Módulo Expo nativo | Coste inicial |
|---|---|---|---|---|---|
| Clerk | Apps SaaS, B2B con organizaciones | Sí | Sí | Sí (@clerk/clerk-expo) | Free hasta 10.000 MAU |
| Supabase Auth | Stack open-source con Postgres + RLS | Sí | No | Parcial | Free hasta 50.000 MAU |
| Auth0 | Enterprise, cumplimiento SOC2/HIPAA | Sí | Sí | Vía react-native-auth0 | Free hasta 25.000 MAU |
| Firebase Auth | Apps con stack Google (Firestore, FCM) | Sí | Limitado | Vía @react-native-firebase/auth | Free |
| DIY (JWT propio) | Cuando ya tienes un backend con auth | Manual | Manual | — | Solo infraestructura |
La regla pragmática: si no tienes razones muy específicas para construir tu propio sistema, usa un proveedor. La autenticación es un dominio donde un fallo no se nota hasta que es tarde, y los proveedores ya resolvieron casos límite (renovación de tokens, deep links interrumpidos, sesiones simultáneas) que tú aún no conoces. Honestamente, esa es una de las lecciones que aprendí a las malas en mi primer proyecto serio.
Almacenamiento seguro de tokens en React Native
Las apps móviles no pueden depender de cookies HttpOnly como sí hacen las webs. Los tokens deben guardarse en almacenamiento seguro específico de cada plataforma: iOS Keychain en iOS y Android Keystore en Android. expo-secure-store envuelve ambas APIs con una interfaz JavaScript unificada, lo cual es enorme cuando estás cansado un viernes por la tarde.
npx expo install expo-secure-store
import * as SecureStore from 'expo-secure-store';
export const tokenStorage = {
async getItem(key: string) {
return SecureStore.getItemAsync(key);
},
async setItem(key: string, value: string) {
return SecureStore.setItemAsync(key, value, {
keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK,
requireAuthentication: false,
});
},
async removeItem(key: string) {
return SecureStore.deleteItemAsync(key);
},
};
Hay una limitación que duele descubrir tarde: expo-secure-store tiene un tope de 2.048 bytes por valor. Una sesión completa de Supabase, con refresh token y metadatos, supera ese límite sin despeinarse. La solución recomendada en 2026 es generar una clave AES-256 una sola vez, guardarla en SecureStore y cifrar la sesión completa en MMKV:
import { MMKV } from 'react-native-mmkv';
import * as SecureStore from 'expo-secure-store';
import * as Crypto from 'expo-crypto';
const ENCRYPTION_KEY_NAME = 'session-encryption-key';
async function getOrCreateEncryptionKey() {
let key = await SecureStore.getItemAsync(ENCRYPTION_KEY_NAME);
if (!key) {
const bytes = await Crypto.getRandomBytesAsync(32);
key = Buffer.from(bytes).toString('base64');
await SecureStore.setItemAsync(ENCRYPTION_KEY_NAME, key);
}
return key;
}
export async function createSecureStorage() {
const encryptionKey = await getOrCreateEncryptionKey();
return new MMKV({ id: 'auth-session', encryptionKey });
}
Este patrón —clave en Keychain/Keystore, datos cifrados en MMKV— es lo que usan Clerk y otras librerías serias por defecto. No te lo estás inventando; es el camino bien pavimentado.
Configurar Clerk en una app Expo Router
Clerk se ha consolidado como la opción más rápida para apps con autenticación moderna (passkeys, magic links, OAuth, MFA). Su módulo nativo @clerk/clerk-expo ya incluye el tokenCache cifrado, así que no tienes que cablear el almacenamiento manualmente. Una cosa menos en la lista.
Instalación
npx create-expo-app@latest mi-app --template blank-typescript
cd mi-app
npx expo install @clerk/clerk-expo expo-secure-store expo-auth-session expo-web-browser expo-crypto
Crea una aplicación en el dashboard de Clerk, copia la publishable key y guárdala en .env:
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxxx
Envolver la app con ClerkProvider
// app/_layout.tsx
import { ClerkProvider } from '@clerk/clerk-expo';
import { tokenCache } from '@clerk/clerk-expo/token-cache';
import { Slot } from 'expo-router';
export default function RootLayout() {
return (
<ClerkProvider
tokenCache={tokenCache}
publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
>
<Slot />
</ClerkProvider>
);
}
El tokenCache de Clerk persiste la sesión cifrada en el llavero del dispositivo. La autenticación sobrevive a reinicios sin pedirle al usuario que vuelva a iniciar sesión. Justo lo que esperas, sin sorpresas.
Pantalla de inicio de sesión con email y contraseña
// app/(auth)/sign-in.tsx
import { useSignIn } from '@clerk/clerk-expo';
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { Button, TextInput, View, Text } from 'react-native';
export default function SignInScreen() {
const { signIn, setActive, isLoaded } = useSignIn();
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
async function onSubmit() {
if (!isLoaded) return;
try {
const attempt = await signIn.create({ identifier: email, password });
if (attempt.status === 'complete') {
await setActive({ session: attempt.createdSessionId });
router.replace('/');
}
} catch (err: any) {
setError(err?.errors?.[0]?.message ?? 'No se pudo iniciar sesión');
}
}
return (
<View style={{ padding: 24, gap: 12 }}>
<TextInput placeholder="Email" autoCapitalize="none" value={email} onChangeText={setEmail} />
<TextInput placeholder="Contraseña" secureTextEntry value={password} onChangeText={setPassword} />
<Button title="Entrar" onPress={onSubmit} />
{error && <Text style={{ color: 'red' }}>{error}</Text>}
</View>
);
}
Rutas protegidas con Stack.Protected (Expo Router v5+)
Hasta SDK 52, proteger rutas en Expo Router implicaba escribir useEffects con router.replace repartidos por varios _layout.tsx. Un dolor. Expo Router v5 (SDK 53, finales de 2025) introdujo Stack.Protected, que mueve la guardia al árbol de navegación y elimina ese problema horrible de pantalla parpadeante.
Patrón básico
// app/_layout.tsx
import { Stack } from 'expo-router';
import { useAuth } from '@clerk/clerk-expo';
export default function RootLayout() {
const { isSignedIn, isLoaded } = useAuth();
if (!isLoaded) return null;
return (
<Stack>
<Stack.Protected guard={!isSignedIn}>
<Stack.Screen name="(auth)/sign-in" />
<Stack.Screen name="(auth)/sign-up" />
</Stack.Protected>
<Stack.Protected guard={isSignedIn}>
<Stack.Screen name="(app)/index" />
<Stack.Screen name="(app)/profile" />
</Stack.Protected>
</Stack>
);
}
Si el usuario intenta entrar a una pantalla protegida sin sesión, Expo Router lo redirige automáticamente a la primera pantalla disponible —sin useEffect ni redirecciones manuales. Lo mismo funciona con Tabs.Protected y Drawer.Protected. La primera vez que lo usé pensé "¿en serio era todo lo que faltaba?". Sí, lo era.
Control de acceso por roles
Stack.Protected no se limita a "logueado / no logueado". El guard acepta cualquier expresión booleana, así que puedes proteger pantallas por rol o permiso:
const { user } = useUser();
const isAdmin = user?.publicMetadata?.role === 'admin';
return (
<Stack>
<Stack.Protected guard={isAdmin}>
<Stack.Screen name="admin/dashboard" />
</Stack.Protected>
</Stack>
);
Limitación importante
Stack.Protected evalúa la guardia en el cliente. Si publicas la web vía Expo Router, los archivos HTML/JS de las rutas protegidas siguen siendo descargables si alguien conoce la URL. Es una buena defensa contra navegación accidental, no un sustituto de la validación en el servidor. Tenlo presente antes de irte a dormir tranquilo.
Alternativa: Supabase Auth
Si prefieres un stack open-source con Postgres, Supabase Auth es la opción más popular en 2026. La configuración cambia un poco, pero el patrón de rutas protegidas con Expo Router es idéntico —solo sustituyes useAuth de Clerk por tu propio hook sobre supabase.auth.
npx expo install @supabase/supabase-js @react-native-async-storage/async-storage react-native-url-polyfill
// lib/supabase.ts
import 'react-native-url-polyfill/auto';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(
process.env.EXPO_PUBLIC_SUPABASE_URL!,
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!,
{
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
}
);
// hooks/useSession.ts
import { useEffect, useState } from 'react';
import { Session } from '@supabase/supabase-js';
import { supabase } from '../lib/supabase';
export function useSession() {
const [session, setSession] = useState<Session | null>(null);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
supabase.auth.getSession().then(({ data }) => {
setSession(data.session);
setIsLoaded(true);
});
const { data: sub } = supabase.auth.onAuthStateChange((_e, s) => setSession(s));
return () => sub.subscription.unsubscribe();
}, []);
return { session, isSignedIn: !!session, isLoaded };
}
Ahora puedes usar useSession() dentro del layout raíz exactamente como usaste useAuth() de Clerk. La única diferencia conceptual de fondo es que con Supabase tú gestionas las políticas Row Level Security (RLS) en Postgres, mientras que con Clerk delegas la autorización a su servicio.
OAuth social con expo-auth-session
Para "Iniciar sesión con Google/Apple/GitHub" sin un proveedor de auth como Clerk, expo-auth-session implementa el flujo OAuth 2.0 PKCE estándar. Es, de hecho, lo que usan Clerk y Supabase internamente bajo el capó.
import * as AuthSession from 'expo-auth-session';
import * as WebBrowser from 'expo-web-browser';
WebBrowser.maybeCompleteAuthSession();
const discovery = {
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenEndpoint: 'https://oauth2.googleapis.com/token',
};
export function useGoogleLogin() {
const redirectUri = AuthSession.makeRedirectUri({ scheme: 'miapp' });
const [request, response, promptAsync] = AuthSession.useAuthRequest(
{
clientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID!,
scopes: ['openid', 'profile', 'email'],
redirectUri,
usePKCE: true,
},
discovery
);
return { request, response, signIn: () => promptAsync() };
}
Aviso importante: si añades "Iniciar sesión con Google" en una app que se publica en la App Store, Apple exige que también ofrezcas "Iniciar sesión con Apple" o tu app será rechazada en revisión. Lo aprendí en una review fallida (qué semana aquella) y desde entonces lo añado de entrada.
Biometría: Face ID y huella digital
Pedirle al usuario su contraseña cada vez que abre la app es mala UX, punto. La práctica estándar en 2026 es: el primer login es con email u OAuth, y los siguientes accesos se desbloquean con biometría usando expo-local-authentication.
import * as LocalAuthentication from 'expo-local-authentication';
async function unlockWithBiometrics() {
const hasHardware = await LocalAuthentication.hasHardwareAsync();
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
if (!hasHardware || !isEnrolled) return false;
const result = await LocalAuthentication.authenticateAsync({
promptMessage: 'Desbloquea tu cuenta',
fallbackLabel: 'Usar contraseña',
cancelLabel: 'Cancelar',
disableDeviceFallback: false,
});
return result.success;
}
Combina esto con el token guardado en SecureStore: si la biometría tiene éxito, restauras la sesión sin volver a llamar al backend. Si falla, redirige a la pantalla de login. Sencillo, rápido, y los usuarios lo agradecen mucho.
Deep links y redirección post-login
Un caso límite que muchas apps fallan (y que se nota mucho cuando lo encuentras como usuario): tocas una notificación que apunta a /orders/123, no tienes sesión, te mandan a /sign-in, y tras autenticarte... te dejan en la home. Frustrante. Para evitarlo, captura la URL inicial antes de redirigir:
// app/(auth)/sign-in.tsx
import { useLocalSearchParams, useRouter } from 'expo-router';
const { redirect } = useLocalSearchParams<{ redirect?: string }>();
async function onSignInSuccess() {
router.replace(redirect ?? '/');
}
Y desde el guard, pasa la URL deseada como query param:
router.replace(`/sign-in?redirect=${encodeURIComponent(intendedPath)}`);
Errores comunes y cómo evitarlos
- "Pantalla parpadeante" al abrir la app: ocurre cuando renderizas el contenido antes de que
isLoadedseatrue. Devuelve un splash onullhasta que la sesión se haya restaurado. - Tokens expirados sin renovación: habilita
autoRefreshToken: trueen Supabase y, si usas un backend propio, implementa refresh token rotation. - Guardar el token en
AsyncStorage:AsyncStorageno está cifrado en Android. UsaSecureStoreo MMKV conencryptionKey. - Olvidar limpiar la sesión al desinstalar: en iOS, el Keychain persiste tras desinstalar la app. Llama a
SecureStore.deleteItemAsyncen el primer arranque tras la instalación si detectas un install nuevo. - No manejar
WebBrowser.maybeCompleteAuthSession(): si lo olvidas, el flujo OAuth se queda colgado al volver a la app. Es de los bugs más típicos.
Checklist de seguridad antes de publicar
- Tokens guardados en
expo-secure-storeo MMKV cifrado, nunca enAsyncStorageplano. - Conexiones siempre por HTTPS; configura
NSAppTransportSecurityen iOS para bloquear HTTP. - Validación de entrada en formularios con Zod o Yup, no solo en el cliente.
- MFA habilitado para cuentas administrativas.
- Rate limiting en endpoints de login y registro.
- Logs de auth sin contraseñas, tokens ni emails completos.
- Sesiones con expiración razonable (24h-30 días según sensibilidad).
- Capacidad de cerrar sesión remotamente (revocar refresh tokens en el servidor).
- Cumple OWASP MASVS-AUTH si tu app maneja datos sensibles.
Preguntas frecuentes
¿Cuál es la diferencia entre Clerk y Supabase Auth?
Clerk es un servicio de autenticación con UI prefabricada, organizaciones B2B, passkeys y MFA listos para usar. Supabase Auth viene incluido en el backend de Supabase (Postgres + Storage + Edge Functions) y se integra con políticas RLS para autorización a nivel de base de datos. Si solo necesitas auth, Clerk es más rápido. Si vas a usar Postgres y quieres todo en un único proveedor open-source, Supabase. Y oye, es perfectamente válido combinarlos: Clerk para auth, Supabase para datos. Lo hago a menudo.
¿Cómo proteger rutas en Expo Router en 2026?
Usa Stack.Protected (o Tabs.Protected / Drawer.Protected) introducido en Expo Router v5. Pásale una expresión booleana al prop guard y Expo Router redirige automáticamente cuando no se cumple. Reemplaza completamente al patrón antiguo de useEffect + router.replace, que sufría del problema de pantalla parpadeante.
¿Es seguro expo-secure-store?
Sí: en iOS usa el llavero (Keychain Services) y en Android usa EncryptedSharedPreferences respaldado por Keystore. Tiene una limitación: cada valor no puede superar 2.048 bytes. Para sesiones grandes (como las de Supabase), guarda solo una clave AES de 32 bytes en SecureStore y cifra los datos en MMKV con esa clave.
¿Cómo añado "Iniciar sesión con Apple" en Expo?
Usa expo-apple-authentication en iOS —es obligatorio si tu app ofrece otros logins sociales. En Android, complementa con expo-auth-session apuntando al endpoint OAuth de Apple. Clerk y Supabase Auth ya integran Sign in with Apple sin que tengas que hacer las llamadas tú mismo, lo que ahorra bastante código y validación de tokens.
¿Por qué falla mi flujo OAuth tras volver a la app?
El error más frecuente es olvidar llamar a WebBrowser.maybeCompleteAuthSession() en el módulo del archivo donde usas useAuthRequest. Sin esa línea, el navegador externo no notifica a tu app cuando el usuario vuelve y el flujo se queda colgado. Otra causa común es un scheme mal configurado en app.json o un redirectUri que no coincide con el registrado en el proveedor OAuth.
Conclusión
En 2026 ya no hay excusa para una autenticación frágil en React Native. Stack.Protected elimina la complejidad de las guardias manuales, Clerk y Supabase resuelven los casos límite que tu equipo tardaría meses en cubrir, y expo-secure-store + MMKV cifrado dan almacenamiento seguro que cumple con los estándares de la industria.
Mi consejo, después de varios proyectos en producción: empieza por el proveedor que mejor se adapte a tu stack, copia los snippets de este artículo, y dedica tu tiempo al producto, no a reescribir refreshToken(). De verdad, no merece la pena.