Proteger rutas y funciones de servidor con guardias
Autorizar es responder «¿puede este usuario ver esto?» en el único sitio donde la respuesta no se puede eludir: junto al dato. Esta lección construye guardias reutilizables sobre la sesión —una `query` `getUser` que resuelve al usuario y un `requireUser` que hace `throw redirect('/login')` cuando no hay sesión— y los aplica en las dos superficies protegibles de SolidStart: dentro de una función de servidor, que comprueba antes de devolver, y en el `preload` de una ruta, donde el `throw` dispara la redirección del router. Cierra distinguiendo el guardia real, en el servidor, del `Show` del cliente, que solo esconde interfaz.
Autenticar es saber quién eres; autorizar es decidir qué puedes ver. La lección anterior te dejó una sesión fiable; esta convierte esa sesión en una barrera. El principio rector —que ya asomó al hablar del middleware— es que la decisión de autorización debe vivir lo más cerca posible del dato, dentro de la misma función de servidor que lo devuelve, porque solo ahí es imposible de eludir: no hay forma de obtener el recurso sin invocar la función que primero pregunta si puedes. Sobre esa idea construiremos guardias reutilizables —un getUser que resuelve al usuario y un requireUser que redirige si no hay sesión— y los aplicaremos a las dos superficies que SolidStart permite proteger: las funciones de servidor y las rutas. Y separaremos, sin ambigüedad, el guardia que protege del Show que solo disimula.
- Construir un guardia reutilizable
getUsersobre la sesión, con unrequireUserque hacethrow redirect. - Proteger una función de servidor comprobando la sesión antes de tocar datos sensibles.
- Proteger una ruta con
preload, dejando que elthrow redirectdel guardia dispare la redirección del router. - Distinguir el guardia de servidor —la frontera real— del
Showde cliente, que solo oculta interfaz.
La autoridad vive junto al dato
Un guardia es una función de servidor que resuelve al usuario actual desde la sesión y decide. Lo modelamos como una query para heredar su deduplicación y su caché por petición: si varias partes de la página preguntan «¿quién es el usuario?», la sesión se lee una sola vez. getUser devuelve el usuario o null; nunca lanza, porque hay sitios —una barra de navegación— donde «no hay usuario» es un estado legítimo, no un error.
// src/lib/guardias.ts
import { query, redirect } from "@solidjs/router";
import { getRequestEvent } from "solid-js/web";
import { usarSesion } from "./sesion";
import { buscarUsuario } from "./db";
export const getUser = query(async () => {
"use server";
const event = getRequestEvent();
// atajo: si el middleware ya lo resolvio, reutilizalo
if (event?.locals.user) return event.locals.user;
const sesion = await usarSesion();
const userId = sesion.data.userId;
if (!userId) return null;
return buscarUsuario(userId); // tu acceso a datos
}, "user");
Un guardia reutilizable: requireUser
Cuando «no hay usuario» sí debe cortar el paso, envuelves getUser en un guardia que lanza. requireUser es también una query, y su throw redirect("/login") es una redirección que el router y las funciones de servidor entienden nativamente: no es una excepción que atrapar, es una respuesta HTTP que se propaga. Como devuelve el usuario ya comprobado, a partir de su llamada el tipo es no-nulo y programas sin interrogantes.
// src/lib/guardias.ts (continuacion)
export const requireUser = query(async () => {
"use server";
const user = await getUser();
if (!user) throw redirect("/login"); // corta y redirige
return user; // de aqui en adelante, no-nulo
}, "require-user");
Definir el guardia una vez y reutilizarlo es la clave de que sea infalible: la comprobación existe en un solo lugar, así que no hay forma de que una ruta nueva «se olvide» de protegerse copiando mal el patrón. Centralizar la política es centralizar la corrección.
Proteger una función de servidor
La primera superficie protegible es la propia función de servidor. Llamas a requireUser en su primera línea; si no hay sesión, el throw redirect aborta antes de que ninguna consulta sensible llegue a ejecutarse. La barrera está literalmente delante del dato.
// src/lib/panel.ts
import { query } from "@solidjs/router";
import { requireUser } from "./guardias";
import { cargarPanel } from "./db";
export const getPanel = query(async () => {
"use server";
const user = await requireUser(); // lanza redirect si no hay sesion
return cargarPanel(user.id); // solo se alcanza autenticado
}, "panel");
Proteger una ruta con preload
La segunda superficie es la ruta. Una ruta de SolidStart puede exportar un objeto route con un preload que arranca la carga de datos antes de renderizar. Si ese preload invoca una query que hace throw redirect, el router captura la redirección y lleva al usuario al login sin pintar la página protegida. El mismo guardia que blinda la función de servidor blinda la ruta.
// src/routes/panel.tsx
import { createAsync, type RouteDefinition } from "@solidjs/router";
import { For, Suspense } from "solid-js";
import { getPanel } from "~/lib/panel";
export const route = {
preload: () => getPanel(), // dispara el guardia antes de renderizar
} satisfies RouteDefinition;
export default function Panel() {
const panel = createAsync(() => getPanel());
return (
<Suspense fallback={<p>Cargando panel…</p>}>
<For each={panel()?.items}>{(i) => <li>{i.nombre}</li>}</For>
</Suspense>
);
}
flowchart TD
A[peticion a una ruta protegida] --> B[preload invoca la query guardia]
B --> C[requireUser lee la sesion]
C --> D{hay usuario}
D -->|si| E[devuelve el dato y renderiza]
D -->|no| F[throw redirect a login]
F --> G[el router navega a login]
style E fill:#a6e3a1,color:#11111b
style F fill:#f38ba8,color:#11111b
style G fill:#f9e2af,color:#11111bEl Show del cliente es UX, no seguridad
En el cliente querrás ocultar un enlace de administración a quien no es admin. Eso se hace con Show leyendo getUser, y está bien —mejora la experiencia—. Pero ten clarísimo su estatus: esconder no es proteger. El Show solo decide qué se pinta; cualquiera puede navegar a la URL a mano o llamar a la función de servidor directamente.
import { Show } from "solid-js";
import { createAsync } from "@solidjs/router";
import { getUser } from "~/lib/guardias";
function EnlaceAdmin() {
const user = createAsync(() => getUser());
// solo OCULTA el enlace; NO protege la ruta /admin ni sus datos
return (
<Show when={user()?.rol === "admin"}>
<a href="/admin">Administracion</a>
</Show>
);
}
La ruta /admin y cada función de servidor que sirva sus datos deben volver a comprobar la autoridad con requireUser (y, para roles, un requireAdmin análogo). El Show y el guardia de servidor no compiten: cooperan. Uno pule la experiencia; el otro es la ley.
Un guardia, muchos usos
requireUser se define una vez y protege funciones de servidor y rutas por igual. La politica vive en un solo sitio.
preload dispara el redirect
El throw redirect del guardia en el preload de la ruta lleva al login antes de pintar nada protegido.
Show es cosmetico
Ocultar interfaz mejora la UX pero no es una barrera. La barrera esta en el servidor, junto al dato.
El error de seguridad más repetido del frontend es confundir «no se ve» con «no se puede». Un Show que oculta un botón, un if que no pinta una sección, una ruta que el enrutador de cliente no muestra: nada de eso detiene a quien abre las herramientas de desarrollo, escribe la URL a mano o llama a tu endpoint con curl. Toda decisión que ocurre en el navegador está bajo el control del usuario, incluido el malicioso. La única autorización que cuenta es la que ejecuta el servidor antes de entregar el dato. Usa el cliente para no enseñar puertas que no llevan a ningún sitio; usa el servidor para cerrarlas con llave.
La razón profunda por la que este patrón funciona no es que sea una convención bonita, sino que explota una asimetría estructural: el dato sensible solo se obtiene ejecutando la función que lo devuelve, y si esa función pregunta por la autoridad en su primera línea, la pregunta es inevitable. No hay atajo, no hay ruta alternativa, no hay caché de cliente que sirva el recurso sin pasar por la función, porque la función es la fuente. Esta es la diferencia abismal entre proteger en el cliente y proteger junto al dato. Una comprobación de cliente es una puerta plantada en mitad de un campo abierto: disuade al que sigue el camino, pero cualquiera la rodea andando por la hierba. Una comprobación de servidor junto al dato es una puerta plantada en el único vano de un muro: no se rodea, porque no hay más que ese vano. Por eso el diseño correcto no dispersa comprobaciones por la interfaz esperando cubrir todos los casos —tarea imposible, siempre falta uno—, sino que concentra la autoridad en el punto de estrangulamiento por el que forzosamente pasa todo acceso. Y por eso conviene una sola función guardia reutilizada en todas partes: si la política vive en requireUser y todas las funciones de servidor sensibles empiezan llamándola, añadir una ruta nueva no puede introducir un agujero por olvido, porque el agujero solo aparece si omites el guardia, y omitirlo es visible en la revisión. La defensa en profundidad remata el cuadro: el middleware puebla el contexto por comodidad, el preload redirige pronto para una buena experiencia de página completa, y la query junto al dato vuelve a comprobar de modo que hasta una llamada directa a la función de servidor —saltándose router y middleware— topa con la barrera. Tres capas, una sola verdad: la autoridad se decide donde el dato se sirve, y ese lugar no se puede eludir.
- Escribe
getUsercomoqueryque devuelve el usuario onullleyendo la sesión, con el atajo deevent.locals.usersi el middleware ya lo resolvió. - Escribe
requireUserque hagathrow redirect("/login")y devuelva el usuario no-nulo; reúsalo en una función de servidorgetPanely confirma que sin sesión nunca llega a consultar datos. - Protege la ruta
/panelexportandoroute.preloadque invoquegetPanel; entra sin sesión y verifica que el router te lleva a/loginsin pintar el panel. - Añade un
Showque oculte un enlace de admin y luego navega a mano a la URL protegida: comprueba que el guardia de servidor te detiene aunque el enlace estuviera oculto. - Llama a la función de servidor protegida directamente (por ejemplo con
fetchdesde la consola) sin sesión y observa que la redirección salta igual: acabas de comprobar que la barrera no depende del cliente.