Autenticación en React Native 2026: Expo Router, Clerk, Supabase y Stack.Protected

Guía práctica 2026 para implementar autenticación segura en apps Expo + Expo Router con Clerk, Supabase Auth, biometría Face ID/huella, almacenamiento cifrado y rutas protegidas con Stack.Protected.

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:

ProveedorMejor paraOAuth socialPasskeysMódulo Expo nativoCoste inicial
ClerkApps SaaS, B2B con organizacionesSí (@clerk/clerk-expo)Free hasta 10.000 MAU
Supabase AuthStack open-source con Postgres + RLSNoParcialFree hasta 50.000 MAU
Auth0Enterprise, cumplimiento SOC2/HIPAAVía react-native-auth0Free hasta 25.000 MAU
Firebase AuthApps con stack Google (Firestore, FCM)LimitadoVía @react-native-firebase/authFree
DIY (JWT propio)Cuando ya tienes un backend con authManualManualSolo 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 isLoaded sea true. Devuelve un splash o null hasta que la sesión se haya restaurado.
  • Tokens expirados sin renovación: habilita autoRefreshToken: true en Supabase y, si usas un backend propio, implementa refresh token rotation.
  • Guardar el token en AsyncStorage: AsyncStorage no está cifrado en Android. Usa SecureStore o MMKV con encryptionKey.
  • Olvidar limpiar la sesión al desinstalar: en iOS, el Keychain persiste tras desinstalar la app. Llama a SecureStore.deleteItemAsync en 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

  1. Tokens guardados en expo-secure-store o MMKV cifrado, nunca en AsyncStorage plano.
  2. Conexiones siempre por HTTPS; configura NSAppTransportSecurity en iOS para bloquear HTTP.
  3. Validación de entrada en formularios con Zod o Yup, no solo en el cliente.
  4. MFA habilitado para cuentas administrativas.
  5. Rate limiting en endpoints de login y registro.
  6. Logs de auth sin contraseñas, tokens ni emails completos.
  7. Sesiones con expiración razonable (24h-30 días según sensibilidad).
  8. Capacidad de cerrar sesión remotamente (revocar refresh tokens en el servidor).
  9. 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.

Sobre el Autor Editorial Team

Our team of expert writers and editors.