La aprobación de cuatro minutos
En el post anterior escribimos: «la criptografía hizo el trabajo que SSO + 2FA hacían antes». Varios ingenieros escribieron preguntando qué significaba eso realmente en código. Este post es la respuesta.
El punto de partida fue una fricción medible en los workflows de clientes MSP. La IA propone una entrada de tiempo facturable en ConnectWise. Una manager tiene que firmar antes de que se publique. El camino legacy era:
- Notificación de Slack: «aprobación requerida»
- Clic en el enlace → redirección a SSO → contraseña → 2FA → página de aprobación
- Leer la entrada → clic en Aprobar
Tiempo mediano hasta la primera acción: 4 minutos. Multiplicado por 30 entradas/día × 4 técnicos en un MSP típico de 10 puestos, son 8 horas al día de atención manageriall para hacer lo que funcionalmente es un montón de pulgares arriba.
Los 4 minutos no eran porque alguien fuera lento. Eran porque el camino a la aprobación exigía una ceremonia de identidad completa para lo que era, en términos de información, un solo bit (aprobar/rechazar) más texto de razón opcional.
Lo reemplazamos con esto: el correo de la manager contiene botones Aprobar y Rechazar. Un clic. La acción se ejecuta, el log de auditoría registra, la entrada de tiempo se publica en ConnectWise. Sin login. Sin 2FA.
Pasa el cuestionario SOC 2. Así:
Las cuatro propiedades que lo hicieron equivalente al auditor
El enlace en cada botón no es una URL con ?approval_id=42. Es un JWT (JSON Web Token) firmado con la clave privada de nuestro servidor, con cuatro propiedades:
- Firmado. La autenticidad del token es criptográficamente verificable. No confiamos en la barra de URL; confiamos en la firma.
- Limitado en el tiempo. Cada token expira (claim
exp), típicamente 72 horas después de la emisión. Después de eso, el clic no hace nada. - De un solo uso. Un token se puede canjear exactamente una vez. El segundo clic en el mismo botón Aprobar pega un cache de idempotencia y devuelve «ya aprobado por ti en [marca de tiempo]».
- Idempotente. Pase lo que pase con la red — clics duplicados, reintentos, pre-fetch del navegador — la acción subyacente se ejecuta como mucho una vez. Mismo efecto cada vez.
La primera propiedad nos deja saltar SSO. Las otras tres nos dejan saltar las preguntas «¿pero qué pasa si…?» del equipo de auditoría.
El formato de cable
El payload del token (después de jwt.decode pero antes de la verificación de firma) se ve así:
{
"iss": "jiegou.ai",
"aud": "approval.action",
"iat": 1714502400,
"exp": 1714761600,
"jti": "01HZ8B3X9NQK5W2T7P0V4M6Y8R",
"approval_id": "appr_2bF7kQpL9xR3",
"approver_email": "alex@example-msp.com",
"action": "approve",
"account_id": "acc_4nM8tYz2",
"scope": "time_entry"
}
Tres cosas sobre este payload:
- El token vincula la acción a un aprobador específico. Incluso si el correo se reenvía, el destinatario no puede canjearlo como sí mismo — el log de auditoría sigue registrando
alex@example-msp.comcomo aprobador, no al reenviador. (Más sobre la amenaza de correo reenviado abajo.) - El
jties un ULID — único por token, usado como clave de idempotencia en nuestro cache de canje. - El campo
scopele dice al servidor a qué handler de canje despachar.time_entryva por un camino de código diferente quecontent_publishooutbound_message. Mismo patrón JWT, handlers de scope diferentes.
El orden de validación importa
Cuando el clic llega al servidor, validamos en este orden exacto. Si cualquier paso falla, paramos:
- Verificar la firma. Esto es lo primero — antes de parsear, antes de confiar en cualquier campo. Si la firma es inválida, la solicitud recibe un
400 Bad Requestgenérico sin detalles. No filtramos qué campo falló. - Verificar
issyaud. Emisor o audiencia incorrectos → rechazar. Esto atrapa tokens forjados por otros servicios o reproducidos a través de sistemas. - Verificar
exp. Expirado → renderizar una página «este enlace de aprobación ha expirado, por favor solicita uno nuevo». - Verificar
jticontra el cache de canje. Ya canjeado → renderizar respuesta de idempotencia «ya aprobado por [aprobador] en [hora]». - Buscar el registro de aprobación. Si el
approval_idno existe o ya se resolvió a través de un camino diferente (por ejemplo, aprobado en la consola), renderizar «esta aprobación ya se resolvió». - Hacer coincidir
approver_emailcon la lista de aprobadores permitidos de la aprobación. Sialex@...no está en la lista de aprobadores de la política para esta aprobación, rechazar con403. - Ejecutar la acción. Atómicamente: escribir el registro de canje (
jti→ canjeado-en), emitir la entrada de auditoríaapproval_event, despachar el workflow post-aprobación (por ejemplo, publicar la entrada de tiempo en ConnectWise). - Renderizar éxito. Mostrar página de confirmación; no reemitir tokens; no almacenar nada en la barra de URL que pudiera ser re-compartido.
La elección no obvia clave es el paso 1. Verificar antes de parsear. Algunas implementaciones hacen jwt.decode primero para extraer el kid (key ID) para verificación de firma, pero eso significa que ya confiaste en datos sin firmar. Usa una biblioteca JWT que verifique en una sola llamada y rechace tokens malformados antes de exponer los claims.
El modelo de amenazas — cuatro cosas, lo que cada una le cuesta al atacante
Nos sentamos con los cuatro ataques más plausibles y preguntamos: ¿cómo maneja nuestro diseño cada uno?
1. El correo se reenvía a alguien fuera de la empresa
Qué pasa: alguien que no es alex@... hace clic en el botón.
Costo para el atacante: cero — tiene el token en texto plano.
Mitigación: el token vincula la acción a la identidad de alex@... en el log de auditoría. El reenviador no puede canjearlo como sí mismo. La acción se ejecutará, pero se ejecuta como Alex. La próxima revisión de gobernanza saca a la luz «Alex aprobó esto desde la IP 203.0.113.7 (que no es la oficina de Alex)» — y Alex se entera inmediatamente en su próximo login.
Esta es la que no prevenimos completamente con criptografía. La prevenimos operativamente: detección de anomalías en IP/UA del aprobador, resumen semanal de gobernanza, y una página de confirmación de 3 segundos que dice «estás a punto de aprobar como alex@example-msp.com — ¿continuar?». El fraude por reenvío es detectable, no prevenible, con enlaces de correo. Lo documentamos claramente en el cuestionario SOC 2 del cliente.
2. El token se filtra en algún log del servidor
Qué pasa: un token aparece en nuestro log de acceso de nginx, o en un log de webhook de terceros, o un console.log perdido.
Costo para el atacante: depende de la frescura.
Mitigación: tres capas.
expcorto. Expiración de 72 horas significa que un token filtrado está muerto en 3 días. No emitimos tokens de 30 días.- Canje de un solo uso. Si el usuario legítimo hizo clic primero, el token está muerto. La búsqueda en el cache de canje pasa antes de cualquier efecto secundario.
- Higiene de logging. Las URLs de enlaces de acción se hacen pattern-match y se redactan en nuestros logs de acceso e ingestión SIEM. Los tokens nunca llegan a almacenamiento duradero fuera del JWT mismo.
3. Replay — mismo token clicado dos veces
Qué pasa: reintentos de red, doble clic, pre-fetch del navegador, o un replay malicioso.
Costo para el atacante: cero (los replays legítimos son comunes).
Mitigación: la verificación del cache de canje en el paso 4 de la validación es portante. El cache es Redis, indexado por jti, con TTL más largo que exp. El primer canje pone la clave atómicamente (SETNX); cada clic posterior lee el registro existente y devuelve la respuesta idempotente «ya aprobado», incluyendo quién aprobó y cuándo.
La idempotencia no es solo una conveniencia de UX — es la propiedad que deja al sistema ser seguro bajo retry sin filtrar estado.
4. Nuestra clave de firma se compromete
Qué pasa: el secreto HMAC o la clave privada RSA se filtra.
Costo para el atacante: puede forjar tokens arbitrarios para cualquier aprobación.
Mitigación: cuatro capas.
- Aislamiento de clave. La clave de firma vive en nuestro KMS, no en memoria de la aplicación. Los servidores de aplicación llaman al KMS para firmar y verificar; nunca poseen el material de clave en bruto.
- Rotación de clave. Las claves rotan según un calendario publicado; las claves antiguas se aceptan para verificación solamente durante una ventana de gracia igual al
expmás largo emitido. - Tokens scopeados por aprobador. Incluso con compromiso de clave, un atacante que forja un token para
alex@...y hace clic produce una entrada de auditoría bajo la identidad de Alex, lo cual (por la amenaza 1) es detectable. - Respuesta a compromiso. Si detectamos compromiso de clave, revocamos la clave e invalidamos cada token pendiente. Los clientes ven «este enlace de aprobación se invalidó por razones de seguridad; por favor usa la consola para resolver» hasta la reemisión.
Esta es la amenaza más difícil de mitigar completamente, y por eso el sistema tiene un fallback: la cola de aprobación in-console siempre funciona. Las aprobaciones por botón de correo son una adición, no un reemplazo.
El compromiso honesto frente a SSO + 2FA
Las aprobaciones por botón de correo no son estrictamente equivalentes a SSO + 2FA. El compromiso:
| Propiedad | SSO + 2FA | Enlace de acción firmado |
|---|---|---|
| Autentica identidad | Sí (factor + factor) | No — autentica una decisión única atada a una identidad |
| Resiste reenvío de correo | Sí (el usuario reenviado no puede pasar 2FA) | Detectable pero no prevenible |
| Resiste compromiso de clave | Independiente de nuestra infra (IdP federado) | Depende de nuestra higiene KMS |
| Fricción por aprobación | 4 minutos | menos de 5 segundos |
| Equivalente en piste de auditoría | Sí | Sí (misma forma approval_event) |
Somos explícitos con los clientes sobre qué compromiso están eligiendo. La configuración recomendada:
- Aprobaciones de bajo riesgo, alto volumen (entradas de tiempo, borradores de contenido, retenciones de calendario): botón de correo por defecto
- Aprobaciones de alto riesgo (transacciones financieras sobre un umbral, compromisos visibles externamente, acciones que afectan cumplimiento): requerir consola + SSO; el botón de correo se reduce a «recordatorio» solamente
- Override por cuenta: cada cliente puede establecer el umbral para qué acciones obtienen botón de correo vs. solo consola
El umbral es un valor de configuración. La criptografía es la misma.
Equivalencia de log de auditoría
Cada canje de enlace de acción firmado emite la misma forma approval_event que una aprobación de consola. El campo entry-point lee email_button; el resto es idéntico:
{
"event_type": "approval.resolved",
"approval_id": "appr_2bF7kQpL9xR3",
"approver_email": "alex@example-msp.com",
"decision": "approve",
"decision_at": "2026-04-29T14:32:18Z",
"entry_point": "email_button",
"client_ip": "203.0.113.42",
"user_agent": "Mozilla/5.0 …",
"jti": "01HZ8B3X9NQK5W2T7P0V4M6Y8R"
}
El entry_point es lo único que el auditor necesita saber que vino de un correo. Todo lo demás — quién, cuándo, qué, IP — coincide con la evidencia de aprobación de consola. Nuestro formato de exportación de evidencia SOC 2 los trata idénticamente. El auditor lee un esquema.
Cinco otros workflows que encajan en este patrón
Una vez que el patrón JWT-cache-de-canje existió, encontramos que encajaba en muchos workflows de decisión única:
- Confirmaciones de retención de calendario — la IA propone «bloquear 30 minutos el miércoles a las 14:00 para preparación de la revisión Q3»; el operador confirma o lo mueve
- Hold-and-release de publicación de contenido — borradores que pasan checks automáticos pero están marcados para confirmación del remitente
- Retenciones de mensajes salientes — la IA borradorea una respuesta del cliente; el operador la libera de una cola retenida
- Desbloqueos de integración sensible — la IA solicita permiso para usar una herramienta específica que está detrás de una puerta de aprobación por uso
- Flujos de consentimiento únicos — consentimiento de cara al cliente para acciones que afectan sus datos
Cada uno de estos solía requerir una visita a la consola. Cada uno ahora tiene un equivalente botón de correo. La criptografía es la credencial. El día del operador se reduce en consecuencia.
Lo que le diríamos a otro equipo construyendo esto
Si estás construyendo algo similar, tres cosas a abrazar y tres a evitar:
Abrazar:
- Verificación de firma JWT antes de cualquier parseo de campo. Usa una biblioteca que haga eso en una sola llamada.
- Idempotencia del cache de canje indexada por
jti. Esto es lo que lo hace retry-safe. - Misma forma de evento de auditoría independientemente del punto de entrada. A los auditores no debería importarles de qué camino vino el clic.
Evitar:
explargo para «ser amable». Un enlace de aprobación de 30 días es una superficie de ataque de 30 días. 72 horas es de sobra para aprobadores humanos.- Almacenar el token en un fragmento de URL esperando que los navegadores no lo registren. Lo harán. Trata las URLs como logs-eventualmente.
- Modos de fallo silenciosos. Cuando la verificación falle, renderiza una página de error clara. El aprobador legítimo puede estar en un token viejo porque ignoró el correo durante una semana.
El eslogan del post anterior era: la confianza no se debilitó, se relocalizó a una capa con menos fricción. Los enlaces de acción firmados son el ejemplo literal de eso. La confianza se movió de SSO + 2FA + gestor de contraseñas + sesión de navegador a clave de firma bien gestionada + JWT con exp corto + cache de canje idempotente. La fricción pasó de 4 minutos a menos de 5 segundos. La piste de auditoría ganó más campos, no menos.
Empieza gratis → — o si operas un MSP y el problema de aprobación de 4 minutos arriba te suena familiar, reserva un walkthrough de 30 minutos y configuraremos tu cuenta con aprobaciones por botón de correo desde el día uno.