Seguridad en APIs: autenticación, autorización y los errores más comunes
Comprobar que un token es válido no es lo mismo que comprobar que su dueño puede acceder a ese recurso concreto. La confusión entre ambas cosas explica el fallo más repetido en auditorías de APIs: BOLA. Guía práctica para diseñarlo bien desde cero.
“El endpoint ya comprueba el token” es, probablemente, la frase que más veces ha precedido a una vulnerabilidad grave en una API. Comprobar el token verifica quién hace la petición. No dice nada sobre si esa persona puede acceder al recurso concreto que está pidiendo. Confundir esas dos preguntas —autenticación y autorización— es tan común que el fallo que produce, Broken Object Level Authorization, lleva años siendo el primer puesto del OWASP API Security Top 10 y explica, según estimaciones del sector, alrededor del 40% de los ataques reales contra APIs.
Autenticación no es autorización, aunque el código a menudo las trate igual
Autenticación responde a “¿quién eres?”: validar un token JWT, comprobar una sesión, verificar una API key. Autorización responde a una pregunta distinta y posterior: “¿puedes hacer esto sobre este recurso concreto?”. Un middleware de autenticación que solo verifica la firma de un JWT y dice “vale, sigue” ha respondido la primera pregunta. Si el handler del endpoint no vuelve a preguntar la segunda —comprobando explícitamente que el userId del token coincide con el propietario del recurso solicitado—, tienes autenticación sin autorización real, que en la práctica es casi tan malo como no tener ninguna de las dos.
// Autenticado, pero NO autorizado a nivel de objeto
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await db.invoice.findUnique({ where: { id: req.params.id } });
res.json(invoice); // cualquier usuario autenticado puede leer la factura de cualquier otro
});
Cualquier usuario con una sesión válida —la suya propia, no la del dueño de la factura— puede leer cualquier factura del sistema simplemente cambiando el id en la URL. El middleware requireAuth hizo su trabajo perfectamente. El bug está en lo que no se comprobó después.
El fallo que más aparece en auditorías: Broken Object Level Authorization (BOLA/IDOR)
BOLA y IDOR (Insecure Direct Object Reference) describen el mismo problema desde dos ángulos: IDOR es el síntoma observable —un identificador de recurso que se puede manipular directamente en la petición—, BOLA es la causa raíz —falta de comprobación de autorización a nivel de objeto. El OWASP API Security Top 10 lo documenta como API1:2023, el primer puesto, con ejemplos reales de la magnitud del problema:
- Una API con el patrón
/shops/{shopName}/revenue_data.jsonque no verifica que quien pide los datos de un comercio sea el dueño de ese comercio, permitiendo leer los ingresos de cualquier negocio de la plataforma cambiando el nombre en la URL. - Una API de un fabricante de coches que acepta un VIN (número de identificación del vehículo) para operaciones remotas sin verificar que ese VIN pertenece al usuario autenticado.
- Un servicio de almacenamiento con una mutación GraphQL de borrado que no comprueba propiedad del documento antes de ejecutarlo, permitiendo borrar archivos de otros usuarios con solo conocer su ID.
El patrón común en los tres casos: el atacante no necesita ningún exploit sofisticado. Necesita una cuenta válida propia —a menudo gratuita— y la capacidad de cambiar un identificador en una petición HTTP normal. Es, con diferencia, la vulnerabilidad de API más fácil de explotar y una de las más difíciles de detectar con escáneres automáticos, porque la petición es sintácticamente idéntica a una legítima; lo único que cambia es si el ID pertenece a quien pregunta.
Su primo cercano: Broken Function Level Authorization (BFLA)
Donde BOLA trata sobre acceder al recurso equivocado, Broken Function Level Authorization (API5:2023) trata sobre ejecutar la acción equivocada: un usuario autenticado y con identidad válida consigue invocar una función o endpoint que debería estar reservado a un rol distinto. El ejemplo típico es un endpoint administrativo (DELETE /api/users/:id, POST /api/admin/refund) que solo comprueba que existe una sesión válida, sin comprobar que esa sesión pertenece a un administrador. En APIs REST aparece también en una variante sutil: un mismo endpoint acepta varios verbos HTTP, y el filtro de rol solo se aplica al verbo “esperado” (GET), dejando DELETE o PATCH sin la misma comprobación.
// BFLA: el rol solo se comprueba en la UI, no en el servidor
app.delete('/api/users/:id', requireAuth, async (req, res) => {
await db.user.delete({ where: { id: req.params.id } }); // cualquier usuario autenticado puede borrar cuentas
res.sendStatus(204);
});
Si el frontend oculta el botón de borrar para usuarios que no son administradores pero el backend no repite esa comprobación, ocultar el botón es una decisión de UX, no un control de seguridad. Cualquiera que sepa la URL del endpoint —y encontrarla no requiere más que abrir las herramientas de desarrollador del navegador— puede invocarlo directamente.
Por qué pasa: el patrón de “autenticado = autorizado”
La raíz de ambos fallos suele ser la misma decisión de diseño, tomada sin mala intención: tratar el middleware de autenticación como si fuera también el control de autorización, porque en el primer prototipo de la aplicación solo existía un tipo de usuario y ambas cosas coincidían por accidente. El problema aparece cuando la aplicación crece —se añaden roles, cuentas de terceros, recursos compartidos entre organizaciones— y nadie vuelve a auditar los endpoints antiguos que se escribieron cuando “usuario autenticado” y “usuario autorizado” eran, de hecho, lo mismo.
Cómo diseñar permisos bien desde el principio
Autorización a nivel de objeto en cada acceso a un recurso, sin excepciones
La regla no tiene atajos: cada vez que un endpoint recibe un identificador que viene del cliente —en la URL, en el body, en un query param— y usa ese identificador para leer, modificar o borrar un recurso, debe verificar explícitamente que el usuario autenticado tiene permiso sobre ese recurso concreto, no solo que está autenticado.
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await db.invoice.findUnique({ where: { id: req.params.id } });
if (!invoice || invoice.ownerId !== req.user.id) {
// 404, no 403: no reveles que el recurso existe si no es del usuario
return res.status(404).json({ error: 'not_found' });
}
res.json(invoice);
});
Devolver 404 en vez de 403 cuando el recurso existe pero no pertenece al usuario es una decisión deliberada: un 403 confirma que el recurso existe, lo cual ya es información filtrada (permite enumerar IDs válidos aunque no se pueda acceder a su contenido).
Elige el modelo de permisos según la forma real de tu dominio, no por moda
| Modelo | Cuándo encaja | Ejemplo |
|---|---|---|
| RBAC (Role-Based Access Control) | Permisos que dependen de un rol fijo, sin matices por recurso | ”Los admins pueden borrar cualquier usuario, los editores no” |
| ABAC (Attribute-Based Access Control) | El permiso depende de atributos del usuario, del recurso o del contexto | ”Puede editar el documento si es el autor, o si pertenece al mismo equipo y el documento no está bloqueado” |
| ReBAC (Relationship-Based Access Control) | El permiso depende de relaciones entre entidades, no de un rol plano | ”Puede ver el proyecto si es miembro del workspace que contiene ese proyecto” (el modelo detrás de Google Zanzibar) |
RBAC es suficiente y más simple de razonar para la mayoría de aplicaciones con pocos roles fijos. En cuanto aparece “depende de si es el dueño”, “depende del equipo al que pertenece” o “depende de si fue invitado a este recurso concreto”, forzar eso dentro de RBAC produce roles artificiales y comprobaciones dispersas por todo el código; ABAC o ReBAC modelan esa realidad de forma mucho más directa.
IDs no adivinables como defensa en profundidad, nunca como sustituto de la autorización
Usar UUIDs en vez de IDs autoincrementales (3fa85f64-5717-4562-b3fc-2c963f66afa6 en vez de 40218) dificulta que un atacante adivine IDs válidos por fuerza bruta secuencial. Es una capa de defensa razonable, pero no resuelve BOLA: si un atacante consigue un UUID válido —por otra fuga de información, por ejemplo un log expuesto o una respuesta de API que devuelve más datos de los necesarios— y el endpoint sigue sin comprobar propiedad, el problema es exactamente el mismo.
Tests de autorización en el pipeline, no en una auditoría anual
La forma más fiable de que BOLA y BFLA no lleguen a producción no es revisar manualmente cada pull request, es automatizar la comprobación: para cada endpoint que opera sobre un recurso identificado, un test de integración que crea dos usuarios de prueba y verifica explícitamente que el usuario B recibe 403/404 al intentar acceder a un recurso del usuario A.
test('un usuario no puede leer la factura de otro', async () => {
const userA = await createTestUser();
const userB = await createTestUser();
const invoice = await createInvoiceFor(userA);
const res = await request(app)
.get(`/api/invoices/${invoice.id}`)
.set('Authorization', `Bearer ${userB.token}`);
expect(res.status).toBe(404);
});
Este tipo de test cuesta pocas líneas por endpoint y detecta exactamente la clase de regresión que introduce BOLA sin que nadie lo note: alguien añade un endpoint nuevo, copia el patrón de otro que sí tenía la comprobación, y en la copia se olvida de incluirla.
Checklist antes de lanzar un endpoint nuevo que toca recursos identificados
- ¿El endpoint recibe algún identificador de recurso desde el cliente (URL, body, query)?
- Si sí: ¿hay una comprobación explícita de que el usuario autenticado tiene permiso sobre ese recurso concreto, no solo que está autenticado?
- ¿Existe un test de integración que verifique que un segundo usuario recibe un error al intentar acceder al recurso del primero?
- Si el endpoint admite varios verbos HTTP o acciones administrativas, ¿se comprueba el rol en el servidor para cada uno, no solo en el que usa la interfaz normal?
- ¿La respuesta de error para “no autorizado” filtra menos información que la respuesta de éxito (404 en vez de 403 cuando aplica, sin exponer si el recurso existe)?
Diseñar autorización correctamente desde el principio no es mucho más caro que hacerlo mal: la diferencia real está en tratarla como parte del contrato de cada endpoint, con su propio test, en lugar de una capa transversal que se asume resuelta porque “ya hay autenticación”. Para la capa anterior a todo esto —cómo autenticar al usuario de forma robusta antes de plantearte qué puede hacer— conviene revisar también passkeys y WebAuthn: guía definitiva, y para el diseño general de la API que envuelve estos endpoints, diseño de APIs REST en 2026.
Artículos relacionados
Passkeys y WebAuthn: la guía definitiva para implementar autenticación sin contraseñas
Las passkeys ya no son una promesa: son el método de login por defecto en miles de sitios. Guía práctica de attestation, assertion, soporte real por navegador y cómo integrarlas con una librería en vez de reinventar WebAuthn desde cero.
Rate limiting y protección de APIs: patrones de producción
El rate limiting mal implementado da una falsa sensación de seguridad. Repasamos los algoritmos que se usan de verdad en producción, dónde aplicarlos, los headers estándar y los fallos que lo dejan sin efecto.
Autenticación moderna: OAuth 2.1, passkeys y el fin (relativo) de las contraseñas
OAuth 2.1 consolida una década de buenas prácticas de seguridad en un único documento, y las passkeys ya superan a la contraseña en tasa de éxito de login. Qué implica esto para tu backend.