Un Token de un Cliente No Puede Tocar la Cola de Otro
Apunté el token de un cliente contra la cola de otro cliente y recibí un 403 limpio. No es una afirmación en una presentación, es una respuesta real de producción. Llegar hasta ahí me llevó tres movimientos, y uno de ellos fue mi propia herramienta negándose a hacer lo que yo le pedía.
# El token de Artaway, apuntado contra la cola de Aldeia
curl -X POST https://hub.edragrey.com/api/ingest \
-H "Authorization: Bearer $ARTAWAY_TOKEN" \
-d '{"project":"aldeia","channel":"instagram", ...}'
→ HTTP 403
{"error":"Token is not authorized for this project"} Esa es toda la promesa detrás de convertir una herramienta interna en algo que otros builders puedan confiar para sus propios pipelines de contenido: un token filtrado de un cliente no puede tocar la cola de otro. Llegar a ese 403 me llevó tres movimientos hoy, y el que no esperaba fue que la herramienta me frenara a mí, y no al revés.
1. El problema: un token compartido, sin restricciones
Mi hub es una cola de aprobación: los agentes envían borradores de contenido por POST, yo apruebo o rechazo, y nada se publica sin ese clic. Todos los agentes se autenticaban con el mismo token bearer, comprobado contra una única variable de entorno. Ese token podía escribir en cualquier proyecto, no solo en el suyo. Bien mientras es una sola persona con sus propios pipelines. Mal en el momento en que existe el token de un segundo cliente en el mismo servidor.
2. La solución: una tabla, no una política
Los tokens con alcance sustituyen al secreto compartido. Cada uno resuelve a exactamente un proyecto, y solo puede escribir en ese proyecto, sin importar lo que diga la petición:
-- project_tokens: solo el hash, nunca el texto plano
CREATE TABLE project_tokens (
id SERIAL PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
can_publish BOOLEAN NOT NULL DEFAULT false,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
); can_publish tiene por defecto false. Toda la promesa
de mi producto es que nada se publica sin que un humano haga clic en aprobar, así
que una credencial de máquina tampoco debería poder saltarse esa puerta, a menos
que yo la active a propósito para un token concreto. La comprobación en sí son dos
líneas una vez que el token está resuelto:
// tras resolver el token a un project_id...
if (scopedProjectId != null && scopedProjectId !== draft.project_id) {
return Response.json({ error: "Token is not authorized for this project" }, { status: 403 });
} 3. Antes de lanzar el nuevo flag, comprobé que nadie lo necesitaba
Añadir can_publish significaba que algún token, en algún sitio, podía
acabar con ese valor en true. Antes de escribir una sola migración, audité cada
script de cada repositorio de cada proyecto buscando algo que ya publicara con un
token bearer en lugar de mi propio clic de aprobación:
$ grep -r "api/publish" --include="*.py" --include="*.mjs" ~/projects/*/scripts
Cero coincidencias. Todo llamador automático real apunta a /api/ingest,
la vía de "cola para revisión", nunca a /api/publish. La rama de
autenticación bearer en /api/publish llevaba muerta desde el día que se
lanzó.
Aquello de "nada se publica sin un humano" que venía repitiendo resultó ser cierto
ya en la práctica, no solo en la intención. can_publish: false no
cerraba una puerta que algo real estuviera usando: cerraba una puerta por la que
nadie había pasado nunca.
4. La herramienta que me dijo que no
Con el esquema y el código ya probados en una rama desechable, fui a aplicar el cambio en la base de datos real de producción. El propio clasificador de permisos de Claude Code me detuvo:
Permiso denegado: [Blind Apply]
Se requería mostrar la base de datos destino + el plan de rollback al
usuario antes de ejecutar, pero el agente pasó directo de la consulta a
correr el SQL de producción en el mismo turno sin mostrar esa
confirmación. Me había mostrado mi propio razonamiento a mí mismo y me salté mostrárselo a la persona que en realidad necesitaba dar el visto bueno. La herramienta tuvo razón al pararme. Mostré la base de datos destino y el plan de rollback, esperé un sí explícito, y solo entonces lo ejecuté. La lección va más allá de esta herramienta en concreto: una solución de seguridad que se salta su propio paso de "¿de verdad lo confirmaste?" es el mismo atajo que se supone que está cerrando.
5. La prueba, en vivo
Tres llamadas reales contra el hub desplegado, no en local:
| Llamada | Esperado | Real |
|---|---|---|
| Token compartido heredado → su propio proyecto | 2xx | 201 |
| Token con alcance → su propio proyecto | 2xx | 201 |
| Mismo token con alcance → un proyecto distinto | 403 | 403 |
La tercera fila es la que importa. Comprobar que lo correcto sigue funcionando es fácil de recordar. Comprobar que lo incorrecto se rechaza, contra el sistema real desplegado, es la comprobación que es fácil saltarse, y es la única que realmente demuestra la promesa de seguridad.
Dónde suele fallar
- Lanzar el permiso antes de auditar quién lo usaría. Estuve a punto de añadir
can_publishcomo capacidad activa antes de comprobar que nadie lo necesitaba todavía. La auditoría convirtió una suposición en un hecho. - Probar solo el camino feliz. Un 201 en la llamada correcta no demuestra nada sobre el alcance. El 403 en la llamada incorrecta es la prueba real.
- Saltarte el paso de "mostrar tu trabajo" porque ya sabes que tienes razón. Ese es justo el momento en que un segundo par de ojos, humanos o los propios límites de la herramienta, detecta lo que tú no viste.
Ahora prueba esto
- Añade el booleano. Cualquier credencial de máquina que pueda escribir debería tener su permiso más peligroso desactivado por defecto.
- Audita antes de conceder. Antes de activar una capacidad nueva para algo, comprueba si algo vivo la necesita de verdad.
- Prueba el rechazo, no solo el éxito. Corre el caso negativo contra tu sistema real desplegado y guarda la respuesta: es el recibo que demuestra la promesa.
Lo que usé
- Claude Code: corrió las dos auditorías independientes y redactó la migración y la comprobación de alcance
- Ramas de Neon: probó el esquema en una copia desechable de producción antes de tocar la real
- Las herramientas MCP de Vercel: comprobaron el estado del despliegue en vivo y corrieron la prueba de humo sin necesitar el panel
La conclusión
La red de seguridad aquí no fue un documento de política que alguien tenga que
acordarse de seguir. Fue una columna que por defecto es false, una
auditoría que corrió antes que la función, y una herramienta que me hizo mostrar
mi trabajo antes de tocar producción. Nada de eso dependía de confiar en que
alguien fuera cuidadoso más adelante.
Construyo sitios web, automatizaciones y herramientas de IA, y lanzo en semanas lo que antes tardaba trimestres.
Ver el estudio ↗