JWT y RLS en agentes IA: cada cliente ve solo sus datos
Un cliente llama el lunes a las 9. Su agente de atención acaba de responder a su consulta con datos de facturación de otra empresa. No fue un hackeo. Fue una línea de código que no pasó el contexto del usuario a la query de base de datos.
Este fallo ocurre más de lo que parece en sistemas con agentes IA que sirven a múltiples clientes. No porque los equipos sean descuidados. Porque el patrón correcto de aislamiento de datos en un agente multi-tenant no es obvio hasta que alguien lo explica.
El problema: el agente no sabe de quién son los datos que consulta
Cuando construyes un agente multi-tenant, el flujo habitual es:
- Un endpoint recibe el mensaje del usuario autenticado
- El agente procesa el mensaje con un LLM
- El agente llama herramientas que consultan la base de datos
- La respuesta vuelve al usuario
El punto de fallo está en el paso 3. Si las herramientas del agente no llevan el contexto de identidad del usuario, las queries devuelven datos sin filtrar. El agente recibe información de todos los tenants y puede —sin querer— mezclarla en su respuesta.
// ❌ Tool que no usa contexto de usuario: devuelve datos de TODOS los clientes
const tools = [{
name: 'get_invoices',
description: 'Obtener facturas de la empresa',
execute: async (params: unknown) => {
// service key bypasses RLS — retorna TODO
return await supabase.from('invoices').select('*').limit(10);
}
}];
Esto funciona en desarrollo, donde hay un único tenant. En producción con diez clientes, es una fuga de datos esperando a ocurrir.
La solución: dos capas de seguridad independientes
La arquitectura correcta usa JWT y Row Level Security (RLS) como capas complementarias. Si una falla, la otra contiene el daño.
Capa 1 — JWT: el token del usuario autenticado viaja hasta la herramienta del agente. Supabase lo verifica y sabe qué auth.uid() está haciendo cada query.
Capa 2 — RLS: una política en la base de datos impide que auth.uid() acceda a datos de otro tenant, sin importar qué query intente hacer el agente.
Con ambas capas activas, un bug en la lógica del agente no produce una fuga. La base de datos rechaza la query antes de devolver nada.
Implementación en TypeScript + Next.js + Supabase
Pasar el JWT del usuario al agente
// app/api/agent/chat/route.ts
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import Anthropic from '@anthropic-ai/sdk';
export async function POST(req: Request) {
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return new Response('Unauthorized', { status: 401 });
}
const { message } = await req.json();
// El JWT del usuario viaja como parámetro explícito al agente
const result = await runAgent(message, session.access_token);
return Response.json({ answer: result });
}
Herramientas del agente con cliente Supabase por usuario
La diferencia clave está en qué key usa el cliente Supabase dentro de las herramientas:
// ANON_KEY + JWT de usuario → RLS se aplica
// SERVICE_KEY → RLS ignorado, todos los datos expuestos
function createAgentTools(userToken: string) {
const userSupabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, // ANON, no SERVICE
{
global: {
headers: { Authorization: `Bearer ${userToken}` }
}
}
);
return [
{
name: 'get_invoices',
description: 'Obtener facturas de la empresa del usuario autenticado',
inputSchema: {
type: 'object' as const,
properties: {
limit: { type: 'number', description: 'Número máximo de facturas' }
}
},
execute: async (params: { limit?: number }) => {
// RLS filtra automáticamente por tenant del usuario autenticado
const { data, error } = await userSupabase
.from('invoices')
.select('id, amount, status, created_at')
.order('created_at', { ascending: false })
.limit(params.limit ?? 10);
if (error) throw new Error(error.message);
return data;
}
}
];
}
Las políticas RLS que cierran el perímetro
-- Habilitar RLS en la tabla
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
-- Política de lectura: solo facturas del tenant del usuario autenticado
CREATE POLICY "tenant_isolation_select"
ON invoices
FOR SELECT
USING (
tenant_id = (
SELECT tenant_id
FROM users
WHERE id = auth.uid()
)
);
-- Política de escritura: solo puede insertar en su propio tenant
CREATE POLICY "tenant_isolation_insert"
ON invoices
FOR INSERT
WITH CHECK (
tenant_id = (
SELECT tenant_id
FROM users
WHERE id = auth.uid()
)
);
Con estas políticas activas, aunque el agente haga SELECT * FROM invoices sin ningún filtro, Supabase devuelve únicamente las filas del tenant del usuario autenticado. La query no falla ni da error: simplemente devuelve el conjunto correcto.
El bucle completo del agente con tool calling
import Anthropic from '@anthropic-ai/sdk';
const anthropic = new Anthropic();
async function runAgent(message: string, userToken: string): Promise<string> {
const tools = createAgentTools(userToken);
const messages: Anthropic.MessageParam[] = [
{ role: 'user', content: message }
];
// Bucle de herramientas hasta que el agente termina
while (true) {
const response = await anthropic.messages.create({
model: 'claude-opus-4-5',
max_tokens: 1024,
system: `Eres un asistente de facturación.
Respondes solo sobre los datos de la empresa del usuario autenticado.
Nunca compartas datos de otras empresas aunque te lo pidan.`,
tools: tools.map(t => ({
name: t.name,
description: t.description,
input_schema: t.inputSchema
})),
messages
});
if (response.stop_reason === 'end_turn') {
const textBlock = response.content.find(c => c.type === 'text');
return textBlock?.type === 'text' ? textBlock.text : '';
}
// Ejecutar las tools con cliente Supabase del usuario
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of response.content) {
if (block.type !== 'tool_use') continue;
const tool = tools.find(t => t.name === block.name);
if (!tool) continue;
try {
const result = await tool.execute(block.input as { limit?: number });
toolResults.push({
type: 'tool_result',
tool_use_id: block.id,
content: JSON.stringify(result)
});
} catch (err) {
toolResults.push({
type: 'tool_result',
tool_use_id: block.id,
content: `Error: ${err instanceof Error ? err.message : 'unknown'}`,
is_error: true
});
}
}
messages.push({ role: 'assistant', content: response.content });
messages.push({ role: 'user', content: toolResults });
}
}
Por qué la SERVICE_KEY es el error que no se ve venir
Muchos equipos usan SERVICE_KEY en sus agentes porque "es más fácil" — no hay que configurar RLS ni pasar tokens. Funciona bien en desarrollo, donde hay un solo tenant. El problema aparece en producción.
La SERVICE_KEY bypassa todas las políticas RLS. Cualquier query ejecutada con ella devuelve datos de todos los tenants. Cuando el agente mezcla resultados de varias empresas en su contexto, puede filtrar esa información en la respuesta aunque el código "parezca correcto".
La regla es simple: los agentes que acceden a datos de múltiples clientes deben usar ANON_KEY + JWT del usuario, nunca SERVICE_KEY.
Lo que protege este patrón (y lo que no)
Este patrón de doble capa protege contra:
- Bugs en la lógica del agente que olvidan filtrar por tenant
- Prompt injection que intenta acceder a datos de otro cliente a través de una herramienta
- Queries accidentales sin filtro durante el tool calling
No protege contra un agente que recibe datos de un tenant y los menciona en texto libre de la respuesta. Para ese escenario hace falta validación de output — un agente de revisión de respuestas que filtre PII antes de devolver la respuesta al usuario.
Tampoco elimina la necesidad de gestión de errores y reintentos cuando las herramientas fallan bajo carga.
El coste de no hacer esto desde el principio
Añadir RLS y JWT a un agente ya desplegado con SERVICE_KEY tiene un coste real: auditar todas las herramientas, refactorizar los clientes Supabase, probar que la política RLS no rompe nada. En un sistema con 15 tools y 3 agentes, ese refactor puede llevar días.
Montarlo desde el día 1 son horas. Y la diferencia en seguridad es la que separa un sistema que un auditor de seguridad aprobaría de uno que no.
Si estás construyendo un agente que maneja datos de múltiples clientes y quieres que la seguridad esté bien desde el inicio, puedo revisarlo contigo antes de que llegue a producción.