Autorización en el Cliente vs. en el Servidor: Por Qué Necesitas Ambas
Autorización en cliente vs servidor en React y Next.js: aplica permisos en el servidor, usa el cliente para la UX y evita desajustes con 403.
La autorización en el cliente es la lógica de acceso que se ejecuta en el navegador y determina lo que los usuarios ven; la autorización en el servidor es la lógica de acceso que se ejecuta en el servidor y decide lo que realmente ocurre — y ninguna restricción en la interfaz cambia lo que el servidor aceptará. Son dos trabajos distintos en dos fronteras de confianza diferentes, y omitir cualquiera de ellas produce un fallo específico y predecible: las verificaciones solo en el cliente pueden ser eludidas por cualquier persona que abra DevTools, y las verificaciones solo en el servidor dejan a los usuarios haciendo clic en botones que devuelven errores 403 sin entender por qué.
Si has publicado una aplicación en React o Next.js donde las verificaciones de roles viven en bloques if (user.role === 'admin'), ya has escrito autorización en el cliente — puede que simplemente no tengas claro dónde se encuentra el límite de seguridad real. Este artículo traza ese límite con precisión: por qué el cliente no es de confianza, por qué el servidor es el único guardián que importa, para qué sirven legítimamente las verificaciones en el cliente, y cómo mantener ambas capas sincronizadas usando un único identificador de permiso aplicado en ambos lados.
Puntos Clave
- El cliente es un entorno no confiable: cualquier usuario puede abrir DevTools, modificar la variable que almacena su rol y observar cómo responden tus renderizados condicionales — por lo tanto, las decisiones de autorización deben aplicarse en el servidor, en un gateway, o en una función serverless (OWASP Authorization Cheat Sheet).
- La autorización en el cliente existe para la experiencia de usuario, no para la seguridad: oculta botones, restringe rutas y muestra solo los elementos de menú que corresponden, para que los usuarios nunca lleguen a un callejón sin salida.
- El estado de permisos del frontend es una caché de lo que el servidor permitirá; cuando ambos divergen, se produce o bien un flujo de trabajo susceptible de phishing (la UI muestra un botón que el servidor responde con 403) o una capacidad oculta (el servidor permite una acción que la UI nunca expone).
- Usa identificadores de permiso como
tasks:deleteen ambas capas, no comparaciones de nombres de rol comorole === 'admin'— los nombres de permisos sobreviven a reestructuraciones de roles y coinciden con la unidad que el servidor realmente aplica. - Un 401 significa no autenticado y debe redirigir al login; un 403 significa autenticado pero no autorizado y debe mostrar un estado de permiso denegado (RFC 9110 §15.5.2, §15.5.4).
El cliente es un entorno no confiable
Cada verificación de rol en tu JavaScript se ejecuta en un entorno que el usuario controla: pueden abrir DevTools, encontrar la variable que almacena su rol, establecerla en 'admin' y observar cómo responden tus renderizados condicionales — la única cosa que no cambia es la respuesta del servidor a su siguiente solicitud. Este es el hecho fundamental que hace que la autorización solo en el cliente sea insuficiente por sí sola. El navegador ejecuta tu código, pero el usuario es dueño del navegador, y puede reescribir cualquier cosa entre la carga de la página y la siguiente solicitud de red.
La OWASP Authorization Cheat Sheet establece la regla práctica directamente: las verificaciones de control de acceso en el cliente pueden mejorar la experiencia de usuario, pero las decisiones de autorización deben aplicarse en el servidor, en un gateway, o en una función serverless — porque la lógica del lado del cliente es fácil de eludir. El marco conceptual es anterior a las SPAs modernas; la guía de Static Apps sobre autenticación lo expresó así: “el usuario final puede ejecutar código arbitrario en el cliente sin permiso previo.”
Una evasión en 30 segundos con DevTools
La demostración más clara de por qué falla la autorización solo en el cliente es eludirla tú mismo. Esto es reproducible en cualquier navegador con DevTools, contra cualquier aplicación React que almacene el rol en el estado del componente en lugar de derivarlo en cada solicitud desde un token verificado por el servidor:
- Inicia sesión como usuario regular (no administrador). El panel de administración está oculto — tu componente renderiza
{user.role === 'admin' && <AdminPanel />}yuser.rolees'user'. - Abre DevTools y localiza el estado del componente que contiene
user. Con React DevTools puedes inspeccionar y editar el estado de los hooks directamente; sin él, cualquier ruta de código que exponga el objeto usuario a una referencia mutable funciona. - Establece
roleen'admin'. React vuelve a renderizar. El panel de administración aparece en el DOM. - Haz clic en el botón de eliminar que expone el panel. La solicitud se envía.
- Si el servidor aplica autorización, lee la identidad de tu token — no de tu estado de cliente modificado — y devuelve
403 Forbidden. Los datos no se han alterado.
El ataque tiene éxito en el paso 3 (la UI cambia) y falla en el paso 5 (el servidor rechaza la solicitud) solo si existe una verificación en el servidor. Sin verificación en el servidor, el paso 4 muta datos reales. La evasión funciona específicamente porque el rol vive en un estado de cliente mutable; no funciona cuando el servidor re-deriva la autorización desde un token verificado en cada solicitud.
El servidor es el único guardián que importa
Discover how at OpenReplay.com.
Las decisiones de autorización que modifican datos o exponen recursos protegidos deben tomarse y aplicarse en el servidor, porque el servidor es el único participante en la solicitud que el usuario no puede reescribir. El backend es el guardián definitivo: independientemente de lo que el frontend renderice, oculte o deshabilite, la evaluación del servidor sobre la solicitud es la decisión de registro. Los controles del frontend no son un sustituto de la aplicación en el backend.
A continuación se muestra la aplicación de autorización en el servidor para un único permiso, tasks:delete, como un Route Handler de Next.js. El permiso se lee desde la sesión autenticada, no desde nada que el cliente envíe en el cuerpo:
// app/api/tasks/[id]/route.ts — Next.js App Router
import { NextRequest, NextResponse } from 'next/server';
import { getSessionPermissions } from '@/lib/auth';
import { deleteTask } from '@/lib/tasks';
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } },
) {
const permissions = await getSessionPermissions(req); // derivado de un token/sesión verificado
if (!permissions.includes('tasks:delete')) {
return NextResponse.json(
{ error: 'forbidden', permission: 'tasks:delete' },
{ status: 403 },
);
}
await deleteTask(params.id);
return new NextResponse(null, { status: 204 });
}
El 403 incluye el nombre específico del permiso en el cuerpo. Ese detalle importa más adelante: permite que el cliente distinga una denegación de permiso de cualquier otro fallo y muestre un mensaje coherente en lugar de un toast genérico. El estado 403 Forbidden es la elección semántica correcta según RFC 9110 §15.5.4, que lo define como el servidor entendiendo la solicitud pero negándose a autorizarla.
La autorización en el cliente es para la interfaz, no para el control de acceso
La autorización en el cliente existe para dar forma a la interfaz: ocultar botones de administración a los no administradores, restringir rutas para que los usuarios no lleguen a un 403, y mostrar solo los elementos de menú que corresponden. Mejora la experiencia guiando a los usuarios hacia acciones que realmente pueden completar — no asegura nada, ni puede hacerlo. Piensa en el frontend como el escaparate, no como la caja fuerte: organiza lo que es accesible y está disponible, mientras que el candado permanece en el servidor.
La implementación estándar lee los permisos desde el contexto y renderiza condicionalmente basándose en un identificador de permiso:
// components/TaskActions.tsx
'use client';
import { usePermissions } from '@/hooks/usePermissions';
export function TaskActions({ taskId }: { taskId: string }) {
const { can } = usePermissions();
return (
<div className="task-actions">
{can('tasks:delete') && (
<button onClick={() => deleteTask(taskId)}>Delete</button>
)}
</div>
);
}
Observa el identificador: tasks:delete, no role === 'admin'. Verificar user.role === 'admin' acopla tu UI a tu taxonomía de roles; verificar can('tasks:delete') la acopla a la acción que el servidor realmente aplica — y al servidor no le importa qué rol la concedió. Cuando más adelante dividas admin en admin y billing-admin, las verificaciones con nombres de permisos sobreviven intactas. La OWASP Authorization Cheat Sheet recomienda separar roles de permisos exactamente por esta razón. RBAC sigue siendo un modelo de control de acceso común para aplicaciones frontend; para la pregunta completa de selección entre RBAC/ABAC/ACL/PBAC, la comparación de modelos de control de acceso de LogRocket lo cubre bien.
Una nota técnica importante: las reproducciones de sesión de interfaces con restricciones por rol frecuentemente muestran el breve momento en que un usuario no autorizado ve el botón de administración antes de que una verificación de permisos obtenida del cliente se resuelva y lo oculte — un destello de UI no autorizada causado por el estado de permisos que llega después del primer renderizado. La solución es renderizar el estado de permisos en el servidor o precargarlo antes del renderizado, lo que el patrón de transferencia de la siguiente sección hace por diseño.
Las dos capas deben coincidir: la deriva de permisos
La vista de permisos del frontend es una caché de lo que el servidor permitirá, y ambos deben mantenerse sincronizados. Cuando el estado de permisos del frontend diverge de las reglas de aplicación del servidor, se produce uno de dos fallos: un botón al que el servidor responderá con 403 (un flujo de trabajo susceptible de phishing), o una acción que el servidor permite pero que la UI nunca expone (una capacidad oculta). El primero es un problema de seguridad y de experiencia de usuario — estás mostrando elementos de acción que no funcionan, y un atacante puede estudiar cuáles honra realmente el servidor. El segundo es una pérdida pura de capacidad — los usuarios no pueden acceder a algo a lo que tienen derecho.
La forma de prevenir la deriva es estructural: define una única fuente de verdad y alimenta ambas capas desde ella.
El patrón de transferencia
El patrón de transferencia es una arquitectura de autorización en la que el servidor mantiene la única fuente de verdad para los permisos y el cliente mantiene una caché de esa verdad — poblada en el login, utilizada para renderizar la interfaz, pero orientativa en lugar de autoritativa. El circuito completo funciona así: el servidor decide los permisos del usuario, los envía al cliente en un formato de transferencia conocido, el cliente los almacena en caché en el contexto, la UI se renderiza contra la caché, el usuario actúa, y el servidor re-verifica en la solicitud. El mismo identificador de permiso aparece en cada paso.
Comienza con el formato de transferencia. En el login (o en el renderizado inicial del Server Component), el servidor emite el conjunto de permisos:
{
"userId": "u_8123",
"permissions": ["tasks:read", "tasks:create", "tasks:delete"]
}
Si transportas permisos en un JWT, viven como claims en el payload del token. Un JWT firmado (un JWS) lleva claims en un payload codificado en base64url que está protegido en su integridad, no cifrado — la firma prueba que los claims no fueron manipulados, pero los claims no son secretos y son legibles por el cliente. Los JWT cifrados (JWEs) son una construcción diferente. Esta distinción proviene del RFC 7519, la especificación JWT. La consecuencia práctica: los claims de permisos de un JWT son una caché perfectamente válida para el renderizado, pero siguen siendo solo una caché. El servidor debe validar la autorización en cada solicitud usando cualquier fuente de verdad que utilice tu arquitectura.
Carga la caché en el contexto una sola vez, idealmente desde un Server Component para que los datos estén presentes en el primer renderizado:
// app/layout.tsx — Server Component, Next.js App Router
import { getSessionPermissions } from '@/lib/auth';
import { PermissionsProvider } from '@/hooks/usePermissions';
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const permissions = await getSessionPermissions(); // misma fuente que usa la API
return (
<html lang="en">
<body>
<PermissionsProvider permissions={permissions}>
{children}
</PermissionsProvider>
</body>
</html>
);
}
// hooks/usePermissions.tsx
'use client';
import { createContext, useContext } from 'react';
const PermissionsContext = createContext<string[]>([]);
export function PermissionsProvider({
permissions,
children,
}: {
permissions: string[];
children: React.ReactNode;
}) {
return (
<PermissionsContext.Provider value={permissions}>
{children}
</PermissionsContext.Provider>
);
}
export function usePermissions() {
const permissions = useContext(PermissionsContext);
return { can: (p: string) => permissions.includes(p) };
}
Dado que getSessionPermissions() es la misma función que llama el Route Handler, la UI y el control de acceso leen datos idénticos, y tasks:delete significa lo mismo en ambos lados. Cargar los permisos en un Server Component (en lugar de obtenerlos en un useEffect del cliente) significa que el conjunto de permisos está disponible antes del primer renderizado — sin destellos de UI incorrecta, sin parpadeos mientras se resuelve una verificación.
React Server Components y Server Actions: el límite es sintáctico
En el App Router de Next.js, las palabras “cliente” y “servidor” describen dónde se ejecuta el código, no una llamada de red que realizas manualmente — y esa distinción es lo que hace que los Server Actions sean un lugar legítimo para aplicar autorización. Un Server Action de Next.js que llama a una verificación de permisos antes de mutar datos es autorización en el servidor, independientemente de dónde se encuentre en el árbol de componentes: el código se ejecuta en el servidor, el usuario no puede modificarlo, y la verificación no puede ser eludida editando el estado del cliente.
En la documentación actual del App Router de Next.js, la directiva 'use server' marca una Server Function; una Server Function utilizada en un contexto de acción/mutación también se denomina Server Action. La directiva es el marcador del límite — el código bajo ella se ejecuta en el servidor aunque esté escrito en un archivo .tsx junto a componentes del cliente:
// app/tasks/actions.ts — Next.js App Router
'use server';
import { getSessionPermissions } from '@/lib/auth';
import { deleteTask } from '@/lib/tasks';
export async function deleteTaskAction(taskId: string) {
const permissions = await getSessionPermissions(); // se ejecuta en el servidor
if (!permissions.includes('tasks:delete')) {
return { ok: false as const, error: 'forbidden', permission: 'tasks:delete' };
}
await deleteTask(taskId);
return { ok: true as const };
}
Esta es la misma verificación de tasks:delete que el Route Handler, en la misma posición de aplicación. Editar el estado del cliente para simular role === 'admin' no tiene ningún efecto aquí — la acción re-deriva los permisos desde la sesión en el servidor. Los Server Actions no eliminan la necesidad del control de acceso; son otro lugar donde ese control reside. Consulta la documentación de la directiva use server de Next.js para conocer la semántica completa de la directiva.
Cuando tu SPA llama directamente a una API de terceros
Cuando tu SPA llama directamente a una API de terceros desde el navegador, el tercero es tu servidor a efectos de autorización — un proveedor de pagos, un CMS headless, o un backend-as-a-service es el guardián, independientemente de si lo escribiste tú o no. El límite de aplicación no desaparece porque no hayas escrito el backend; se desplaza a quien evalúa la solicitud. Tus verificaciones en el cliente siguen siendo solo para la experiencia de usuario, y las reglas de autorización del tercero son el control de acceso. Si no puedes aplicar una decisión en código que controlas, enruta la solicitud a través de tu propio backend para poder hacerlo.
Modos de fallo: qué se rompe cuando omites una capa
Omitir cualquiera de las capas produce un fallo distinto y predecible. La autorización solo en el cliente es trivialmente eludible; la autorización solo en el servidor deja a los usuarios confundidos. Ninguna es aceptable por sí sola.
| Capa omitida | Qué se rompe | Quién lo explota | Experiencia de usuario |
|---|---|---|---|
| Servidor (solo cliente) | La autorización no se aplica en absoluto | Cualquier usuario con DevTools o curl | Parece correcto — hasta que alguien que no debería altera los datos |
| Cliente (solo servidor) | La UI muestra acciones que el servidor rechazará | Sin exploit; es un defecto de UX | Los usuarios hacen clic en un botón, reciben un 403, vuelven a hacer clic y se rinden |
La regla es lo suficientemente corta como para recordarla: renderiza con permisos del cliente, aplica con permisos del servidor, y utiliza los mismos identificadores de permisos en ambos. Violar lo primero hace que los usuarios hagan clic en botones que devuelven 403. Violar lo segundo permite que cualquier usuario con DevTools se promueva a sí mismo.
La experiencia de usuario ante fallos de autorización: 403 vs 401
Cuando el servidor rechaza una acción que la UI consideraba permitida, el código de estado de la respuesta te indica exactamente qué mostrar al usuario. Un 401 significa que el usuario no está autenticado — redirige al login. Un 403 significa que está autenticado pero no autorizado — muestra un estado de permiso denegado, no un error genérico, para que el usuario entienda por qué falló la acción. Estas semánticas están fijadas por RFC 9110: §15.5.2 define 401 Unauthorized (se requiere autenticación y ha fallado o no se ha proporcionado), y §15.5.4 define 403 Forbidden (el servidor entendió la solicitud pero se niega a autorizarla).
async function deleteTask(taskId: string) {
const res = await fetch(`/api/tasks/${taskId}`, { method: 'DELETE' });
if (res.status === 401) {
window.location.assign('/login');
return;
}
if (res.status === 403) {
const body = await res.json();
showPermissionDenied(body.permission); // ej. "No tienes permiso para eliminar tareas."
return;
}
if (!res.ok) {
showError('Algo salió mal. Inténtalo de nuevo.');
return;
}
// éxito
}
Tratar el 403 de forma diferenciada es donde la deriva entre cliente y servidor se hace visible para el usuario. Las reproducciones de sesión de respuestas 403 comúnmente muestran a un usuario haciendo clic en un botón, recibiendo un toast de error genérico, y volviendo a hacer clic — una señal de que la UI nunca comunicó que la acción fue denegada específicamente por razones de permisos. Exponer el nombre del permiso desde el cuerpo de la respuesta (el campo permission: 'tasks:delete' que envió el servidor) cierra esa brecha. El RFC define lo que significa el 403; cómo lo presentas es tu decisión, pero el significado es lo que la UI debe reflejar.
Conclusión
La autorización vive en dos fronteras cumpliendo dos funciones: el cliente da forma a la interfaz, el servidor decide lo que ocurre, y los mismos identificadores de permisos fluyen hacia ambos desde una única fuente de verdad en el servidor. Audita tu propio código en busca de los dos modos de fallo — busca verificaciones de roles o permisos que restrinjan mutaciones de datos sin una aplicación correspondiente en el servidor (una evasión esperando ocurrir), y busca verificaciones en el cliente que hayan divergido de lo que devuelve tu API (un 403 que tus usuarios no pueden entender). Donde sea que una verificación modifique datos, confirma que el servidor re-deriva la decisión desde una sesión verificada, no desde nada que el cliente pueda reescribir.
Preguntas Frecuentes
¿Es seguro almacenar permisos de usuario en un JWT si el cliente puede leerlos?
Sí, siempre que trates el token como una caché y no como un límite de seguridad. Un JWT firmado (un JWS) lleva claims en un payload codificado en base64url que está protegido en su integridad pero no cifrado, por lo que el cliente puede leer los permisos, pero la firma impide su manipulación. El riesgo no es la exposición de la lista de permisos; es confiar en ella. El servidor debe validar la autorización en cada solicitud de acuerdo con el modelo de autorización utilizado por la aplicación.
¿Necesita un Server Action de Next.js su propia verificación de autorización si la página ya restringió la UI?
Sí. La restricción en la UI solo controla lo que se renderiza; no protege la mutación. Un Server Action se ejecuta en el servidor y puede invocarse independientemente de la página que lo renderizó, por lo que debe re-derivar los permisos desde la sesión verificada y rechazar las llamadas no autorizadas por sí mismo. La directiva 'use server' marca dónde se ejecuta el código, no si está autorizado. Trata cada Server Action como un punto de aplicación, exactamente igual que un Route Handler.
¿Qué código de estado debe devolver una API cuando un usuario está autenticado pero carece de permiso para una acción?
Devuelve 403 Forbidden, no 401 Unauthorized. RFC 9110 define el 401 como la autenticación requerida que ha fallado o no se ha proporcionado, y el 403 como el servidor entendiendo la solicitud pero negándose a autorizarla. Un usuario autenticado sin permiso está autenticado pero no autorizado, lo que corresponde precisamente al 403. El cliente debe redirigir al login en un 401 y mostrar un estado de permiso denegado en un 403, para que los dos fallos permanezcan visual y conductualmente distintos.
¿Por qué verificar 'tasks:delete' en lugar de 'role === admin' en el frontend?
Los identificadores de permisos sobreviven a reestructuraciones de roles; las comparaciones de nombres de rol no. Verificar 'role === admin' acopla la UI a tu taxonomía de roles actual, por lo que dividir admin en admin y billing-admin más adelante rompe cada verificación. Verificar 'tasks:delete' acopla la UI a la acción que el servidor realmente aplica, que no cambia cuando se reorganizan los roles.