Skip to content
This page has been auto-translated and may contain errors.View in English

Autenticación

Un usuario que inició sesión recarga la página y vuelve a la pantalla de inicio de sesión. Sus credenciales eran válidas. La app renderizó su guardián de rutas antes de terminar de preguntar si existía una sesión, y el guardián interpretó la respuesta faltante como "sin sesión".

Para hacerlo bien, hay que ser preciso sobre qué es una sesión: un pedazo de estado que dice que alguien inició sesión y lleva su identidad, dueño en un solo lugar para que cada componente lea la misma respuesta. El curso construye esto contra un proyecto CRM usando Supabase, pero la estructura es la misma con cualquier proveedor de auth: Firebase, Auth0, Clerk, o tu propio backend. Este capítulo cubre esa estructura.

El estado de sesión vive en un provider

Si un usuario inició sesión importa en todas partes: el router lo necesita para proteger páginas, el header lo necesita para mostrar quién está conectado, los formularios lo necesitan para saber adónde enviar datos. Es exactamente la situación para la que existe el context, y el movimiento estándar es el patrón provider-más-hook de ese capítulo: un AuthProvider es dueño del estado de sesión, y un hook useAuth lo entrega a cualquier componente que lo pida.

El estado de sesión tiene tres valores significativos, y el tercero es el que la gente se pierde:

  • undefined: la verificación no ha terminado. La app cargó y todavía está preguntando al provider si existe una sesión.
  • null: la verificación terminó, y nadie inició sesión.
  • un objeto de sesión: alguien inició sesión, y el objeto lleva su identidad.

Ese tercer valor es lo que arregla el rebote de refresco del inicio. La sesión sigue existiendo en el almacenamiento del navegador, pero por un momento después del primer render la app aún no la ha leído. Empezar en undefined permite que la app renderice un estado de carga breve en lugar de una respuesta incorrecta.

Verificar una sesión existente y escuchar cambios ambos alcanzan fuera de React, así que pertenecen en un effect que se ejecuta una vez después del primer render:

jsx
import { createContext, useContext, useState, useEffect } from 'react'
import { auth } from './authClient'

const AuthContext = createContext(null)

export function AuthProvider({ children }) {
  const [session, setSession] = useState(undefined)

  useEffect(() => {
    async function getInitialSession() {
      const { data } = await auth.getSession()
      setSession(data.session) // el objeto de sesión, o null
    }
    getInitialSession()

    const {
      data: { subscription },
    } = auth.onAuthStateChange((_event, session) => {
      setSession(session)
    })
    return () => subscription.unsubscribe()
  }, [])

  return <AuthContext value={{ session }}>{children}</AuthContext>
}

export function useAuth() {
  const context = useContext(AuthContext)
  if (context === null) {
    throw new Error('useAuth debe usarse dentro de un AuthProvider')
  }
  return context
}

Dos trabajos suceden en ese effect. getSession hace la verificación única: ¿hay una sesión ya guardada en storage de una visita anterior? Luego onAuthStateChange configura un listener, algo como encender una cámara de seguridad, que se dispara cuando el usuario inicia o cierra sesión y mantiene el estado actualizado de ahí en adelante. Los nombres de método varían por proveedor, pero cada SDK de auth ofrece ambos movimientos: lee la sesión actual una vez, y suscríbete a cambios.

Mira el orden

Un getSession lento puede resolverse después de que el listener ya entregó una sesión más nueva y sobreescribirla con la antigua. Muchos SDKs disparan el listener con la sesión inicial al suscribirse, en cuyo caso establecer estado solo desde el listener elimina la carrera.

Qué es un JSON Web Token

Una cadena viaja con cada solicitud para probar quién está pidiendo. Abre devtools en una app que inició sesión, copia esa cadena del almacenamiento, y pregunta qué puede ver la persona que la tiene. La respuesta es todo. Detrás del objeto de sesión hay un JSON Web Token, tres partes separadas por puntos: un encabezado nombrando el algoritmo de firma, un payload de afirmaciones sobre el usuario, y una firma. Cada parte está codificada en base64url, y la codificación es un paso de formato más que encriptación:

js
// un token son tres segmentos base64url unidos por puntos
const [header, payload, signature] = token.split('.')
// atob lee base64 estándar, así que intercambia primero los dos caracteres seguros para URL
JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')))
// { sub: '9f2c...', email: '[email protected]', exp: 1789450000 }

Dos instrucciones en la consola del navegador, ninguna clave involucrada. Así que un payload JWT es legible por cualquiera que tenga el token, lo que hace una regla absoluta: los secretos nunca pertenecen en una afirmación. Las afirmaciones estándar son cosas como sub para la ID del usuario, exp para el tiempo de expiración, y lo que el proveedor decida incluir.

La firma es la parte que un lector no puede falsificar. El proveedor firma el encabezado y el payload con una clave de firma, y en cada solicitud el servidor verifica esa firma. Algunos proveedores usan un único secreto compartido para firmar y verificar, razón por la que ese secreto se queda en el servidor; otros firman con una clave privada y publican una clave pública coincidente que cualquier verificador puede revisar.

De cualquier forma, una firma válida prueba que el token fue emitido aquí y no ha sido alterado desde entonces. No dice nada sobre quién ha leído el contenido.

El token vive en el navegador, en local storage o una cookie, y la librería de cliente lo adjunta a cada solicitud automáticamente. Dónde vive es una decisión de seguridad. El local storage es legible por cualquier JavaScript en la página, así que un bug XSS se convierte en robo de token; las cookies httpOnly son invisibles para los scripts, lo que detiene el robo, aunque un script en la página todavía puede actuar como el usuario mientras se ejecuta, y las cookies traen preocupaciones CSRF que necesitan su propia mitigación.

Llevar el token en cada solicitud es lo que hace el auth JWT sin estado: el servidor no mantiene una lista de quién está actualmente conectado. El token firmado en sí es la prueba, así que el usuario prueba quién es en cada solicitud sin reenviando una contraseña.

Supabase pone una convención encima de esa anatomía, y la convención es de Supabase en lugar de parte del spec JWT: el token de un usuario conectado lleva una afirmación role leyendo authenticated, mientras que una solicitud hecha antes de que alguien inicie sesión obtiene el rol anon.

Esas solicitudes no autenticadas viajan en una clave de API de proyecto que está dentro de tu bundle de cliente: la clave heredada se llama la clave anon y es en sí misma un JWT, y la actual se llama la publishable key, una cadena sb_publishable_... que no es un token en absoluto. Enviarla es segura solo cuando Row Level Security está habilitado en cada tabla a la que pueda llegar, y la siguiente sección cubre esas políticas. Sin ellas, cualquiera que abra devtools puede leer y escribir esas filas.

Mantén la service key fuera del cliente

La contraparte de la publishable key, la clave service-role o secret, omite todas las políticas, así que nunca debe llegar al bundle de cliente o una variable env con prefijo VITE_, ya que esas se compilan en el bundle que envías.

Registrarse, iniciar sesión, cerrar sesión

Los tres flujos comparten una forma: llama el método SDK del proveedor, deja que el listener de auth actualice el estado de sesión, y navega. Cada función de auth típicamente vive en el mismo archivo provider que el estado de sesión, se expone a través del valor de context, y retorna un resultado simple que el componente que llama puede actuar:

jsx
async function signIn(email, password) {
  const { data, error } = await auth.signInWithPassword({
    email: email.toLowerCase(),
    password,
  })
  if (error) return { success: false, error: error.message }
  return { success: true, data }
}

En caso de éxito el proveedor emite un JWT, la librería de cliente lo almacena y construye el objeto de sesión, y el listener onAuthStateChange actualiza el estado. El componente que llamó signIn solo necesita revisar el resultado y navegar:

jsx
const { signIn } = useAuth()

async function handleSubmit(email, password) {
  const { success, error } = await signIn(email, password)
  if (success) navigate('/dashboard')
  else setError(error)
}

Los otros dos flujos son variaciones. Registrarse envía las credenciales del nuevo usuario al proveedor, que las almacena y, con la mayoría de los proveedores, inicia sesión para el usuario al mismo tiempo, así que la misma lógica de navegación aplica. Cerrar sesión llama al método de cierre de sesión del SDK, que borra el token almacenado; el listener ve el cambio, establece la sesión a null, y la app navega de regreso a una página pública.

Nada sobre los flujos es específico de React. El trabajo de React es sostener el estado de sesión y re-renderizar cuando cambia.

Proteger rutas, y quién en realidad refuerza el acceso

Con estado de sesión en context, la ruta de layout requerida de auth del capítulo de rutas protegidas se convierte en un ternario. Los tres valores de sesión obtienen una rama:

jsx
function AuthRequired() {
  const { session } = useAuth()

  if (session === undefined) return <p>Cargando...</p>
  return session ? <Outlet /> : <Navigate to="/signin" replace />
}

Anida las páginas privadas bajo ella y los visitantes no autenticados se redirigen antes de que cualquiera renderice. Ese capítulo hizo el punto de que un guardián de cliente es una característica de experiencia del usuario; aquí está la otra mitad. El control de acceso real tiene que vivir en el servidor, donde los usuarios no pueden llegar.

El ejemplo del curso de refuerzo del lado del servidor es Row Level Security, una característica de base de datos donde las políticas de acceso se adjuntan a las tablas en sí. Habilítalo y todo se deniega por defecto; luego las políticas otorgan acceso basado en afirmaciones en el JWT de la solicitud, como role = 'authenticated' o la columna propietaria de una fila coincidiendo con el sub del token. La política se refuerza dentro de la base de datos en cada consulta, así que se sostiene incluso para una solicitud que salta tu app React.

Lo que permite es exactamente lo que la política dice, y una solicitud llevando nada pero la publishable key es una solicitud legítima, así que una política escrita como siempre-verdadera no protege nada: la regla tiene que nombrar la afirmación que verifica. El guardián de ruta y la política trabajan como un par, y la política es la mitad llevando el peso.

El diseño sin estado es un trueque con dos lados. El servidor se salta una búsqueda de sesión en cada solicitud, que es la ganancia. El costo es la revocación: ya que ninguna lista de sesiones vivas existe, un token robado o cerrado permanece criptográficamente válido hasta que expira.

Los proveedores manejan eso con tokens de acceso de corta vida emparejados con tokens de refresco de larga vida. La librería de cliente esconde la rotación: getSession revisa la expiración del token almacenado, y cuando encuentra un token expirado silenciosamente intercambia el token de refresco por un token de acceso fresco, mostrando null solo cuando ese intercambio falla. La revocación instantánea requiere reintroducir estado del lado del servidor, una lista de negación revisada por solicitud, razón por la que expiraciones cortas son la respuesta por defecto.

Los dos peligros de token de antes tienen un marco más agudo. Una firma garantiza integridad mientras deja la confidencialidad intacta: un verificador aprende que el token no está alterado y no aprende nada sobre quién más lo ha leído en el camino.

En almacenamiento, los SDKs del lado del cliente comúnmente llegan por defecto a local storage y se apoyan en la expiración del token de acceso corto para limitar el daño, que es una apuesta de que un token robado se vuelve obsoleto antes de que valga mucho. Moverse a cookies httpOnly reubica el problema en lugar de removerlo, ya que la mitigación CSRF se convierte en tuya para acertarla.

La regla de refuerzo se generaliza más allá de bases de datos. Row Level Security es una implementación; el principio es que cada decisión de confianza debe suceder donde el atacante no tiene acceso de escritura. Lo que sea que el cliente envíe, incluido el JWT en sí, es entrada controlada por el atacante hasta que el servidor verifica la firma. Esa verificación es el único momento en que entrada no confiable se convierte en identidad confiable, razón por la que la clave de firma es la joya de la corona: quien la tiene puede acuñar tokens válidos para cualquier usuario.

JunoLa sesión es estado, el servidor es la ley Iniciar sesión le da a tu app una sesión, y esa sesión es estado React guardado en un provider de context para que cada componente pueda preguntar "¿alguien inició sesión?" Tiene tres respuestas: aún verificando, sin sesión iniciada, e inició sesión, y la respuesta "aún verificando" evita que los usuarios sean rebotados a la página de inicio de sesión mientras la app busca una sesión existente.

Detrás de todo está un token firmado que viaja con cada solicitud para probar quién está pidiendo, y cualquiera que tenga ese token puede leer lo que hay dentro, así que mantén secretos fuera de él.

Los guardias de ruta redirigen cortésmente a visitantes sin sesión iniciada, pero los bloqueos reales están en el servidor.

JunoLa sesión es estado, el servidor es la ley Construye un AuthProvider con un hook useAuth, exactamente como un proveedor de tema. Inicializa la sesión como undefined, luego en un effect llama a getSession para el caso de refresco y suscríbete a cambios de auth para todo después.

Iniciar sesión, registrarse, y cerrar sesión todos siguen una forma: llama el SDK, deja que el listener actualice el estado, navega en caso de éxito.

Conecta la sesión a una ruta protegida con una rama de carga, y pon las reglas de acceso real del lado del servidor, con políticas como Row Level Security que revisen el JWT en cada consulta.

JunoLa sesión es estado, el servidor es la ley El auth JWT trueca revocación por falta de estado: el token firmado es la sesión, así que nada lo expira temprano sin una lista de negación del lado del servidor, y tokens de acceso de corta vida más rotación de refresco son la mitigación estándar. El payload es legible por cualquiera que tenga el token, así que la firma garantiza integridad mientras secretos se quedan fuera de afirmaciones, y dónde almacenes el token decide si XSS o CSRF es tu problema.

Trata todo lo que el cliente envíe como no confiable hasta la verificación de firma, y refuerza autorización donde el atacante no puede escribir: el servidor.

Lo siguiente: TypeScript en React, donde props, estado, y componentes toman tipos.