
Cuando construyes una herramienta que usa modelos de IA en producción, una de las primeras decisiones es dónde pones las claves de acceso. Si las metes en el JavaScript del navegador, cualquiera puede abrir las herramientas de desarrollo y copiarlas. Si usas un servidor intermedio, tienes que montar una infraestructura que muchos proyectos educativos se saltan "porque es solo una demo". Con ScamDetector quise hacer las cosas bien desde el principio, y eso significaba un proxy server-side que no expusiera ningún secreto al navegador.
En el artículo anterior vimos qué hace ScamDetector y cómo funciona desde la perspectiva del usuario. Este artículo se mete por dentro, en las decisiones de arquitectura que lo hacen funcionar de forma segura y flexible.
ScamDetector es una aplicación de página única (HTML, CSS y JavaScript vanilla, sin frameworks) que se comunica con un servidor Node.js. El servidor actúa como proxy entre el navegador y los servicios externos de IA. El navegador nunca contacta directamente con Google Gemini, Perplexity ni urlscan.io.
Esto tiene tres ventajas que justifican el esfuerzo. Primero, ninguna clave de API llega al cliente. Segundo, el servidor puede aplicar rate limiting, validación y sanitización antes de reenviar la petición. Y tercero, el servidor normaliza las respuestas para que el frontend trabaje siempre con el mismo formato JSON, independientemente de qué backend de IA esté respondiendo por detrás.
ScamDetector soporta dos backends de IA completamente diferentes, seleccionables con una sola variable de entorno (AI_GATEWAY). Suena a sobreingeniería, pero hay una razón práctica detrás.
El primer camino llama a OpenRouter directamente desde el servidor Node.js, sin intermediarios. Un simple fetch() contra la API de OpenRouter con los mismos modelos (Gemini para mensajes, Perplexity Sonar para teléfonos), reintentos automáticos con backoff exponencial para errores 429 y 5xx, y un fallback a Relace Search si Perplexity no responde. Es el camino principal y el más ligero.
El segundo usa Vercel AI Gateway con su SDK propio. El código importa el módulo como ESM y llama a los modelos a través de la capa de abstracción de Vercel, con un fallback diferente que combina Gemini con una herramienta de búsqueda web integrada en el SDK.
Los dos caminos comparten dos piezas que hacen viable mantener dos caminos en paralelo. Un fichero prompts.js centraliza todos los system prompts (análisis de mensajes, análisis de imágenes, reputación de teléfonos, extracción de URLs), así que el contenido que reciben los modelos es idéntico independientemente del gateway. Y una función compartida normalizeResponse() valida tipos, trunca cadenas a los límites máximos y filtra valores inválidos antes de devolver la respuesta al frontend. El frontend no sabe ni necesita saber qué backend le está respondiendo.
¿Por qué dos caminos si con uno bastaría? Porque cubren necesidades diferentes. OpenRouter directo es la opción para despliegue con infraestructura propia, un fetch() limpio sin dependencias externas. Vercel AI Gateway cubre el caso de quien prefiera serverless o quiera la observabilidad integrada de su SDK. Hubo un tercer camino con n8n como orquestador visual, pero lo retiré porque añadía una dependencia pesada que no aportaba lo suficiente frente a la llamada directa. Gracias a los prompts compartidos y a normalizeResponse(), el coste de mantener dos backends es mínimo.
ScamDetector no usa un solo modelo de IA para todo. Usa el modelo adecuado para cada tipo de consulta.
Para el análisis de mensajes y capturas de pantalla usa Gemini 3 Flash. Es rápido, económico y soporta entrada multimodal (texto e imágenes en la misma petición). Con una temperatura de 0.2 y un límite de 1500 tokens de salida, genera respuestas concisas y deterministas en menos de cinco segundos.
Para la consulta de reputación de teléfonos usa Perplexity Sonar, un modelo diseñado específicamente para búsquedas en internet con contexto. La diferencia con usar Gemini para esto es que Perplexity tiene acceso a datos indexados en tiempo real. Cuando le preguntas por un número de teléfono, busca activamente en ListaSpam, Tellows y otras fuentes de reportes de spam españolas.
Si el mensaje incluye tanto texto como un número de teléfono, ambas consultas se ejecutan en paralelo con Promise.all. El usuario no espera que termine una para que empiece la otra.
El rate limiting de ScamDetector aplica un límite de 10 peticiones cada 10 minutos por IP, con buckets separados para el análisis de mensajes y el escaneo de URLs. Lo que hace diferente la implementación es cómo identifica a los usuarios.
En lugar de almacenar las direcciones IP en claro, las hashea con SHA-256 y un salt aleatorio antes de guardarlas. El salt se genera automáticamente en el primer arranque (32 bytes aleatorios), se persiste en /app/data/hash-salt.txt con permisos 600 y se reutiliza en los siguientes reinicios. El resultado es que el fichero de persistencia contiene hashes con salt, no IPs. Los hashes son consistentes entre reinicios (mismo salt), pero incomparables entre instalaciones distintas. Esto permite aplicar el límite por usuario sin crear un registro de quién ha usado el servicio ni cuándo.
Además del límite por IP existe un rate limit global de 100 peticiones cada 10 minutos para todas las IPs combinadas. Esto protege contra ataques de rotación de IPs, donde alguien usa una VPN o una botnet para repartir las peticiones entre muchas direcciones y saltarse el límite individual. Si se supera el límite por IP, el servidor devuelve un 429. Si se supera el global, un 503. La distinción importa porque el frontend muestra mensajes diferentes en cada caso.
Las cabeceras de respuesta informan del estado del rate limit y el frontend muestra un countdown visual cuando te acercas al límite. Tanto los contadores por IP como el contador global se persisten a ficheros JSON en disco, así que reiniciar el contenedor no resetea los contadores. En Dokploy, el directorio /app/data/ está montado como volumen nombrado para que los datos sobrevivan a los redespliegues.
Para la identificación de la IP real detrás de proxies y CDNs, el servidor consulta primero la cabecera de Cloudflare, luego X-Forwarded-For, y finalmente la IP de la conexión directa. Un detalle menor pero necesario para que el límite funcione correctamente en producción detrás de un reverse proxy.
El formulario de ScamDetector incluye dos campos ocultos que un usuario humano nunca verá ni rellenará. Son campos de email y nombre completo marcados con aria-hidden="true", tabindex="-1" y autocomplete="off", invisibles tanto visualmente como para lectores de pantalla.
Los bots que rellenan formularios automáticamente los completan porque no distinguen campos visibles de invisibles. Cuando el servidor detecta que alguno de estos campos tiene contenido, devuelve una respuesta HTTP 200 con un resultado falso de riesgo bajo en lugar de un error. El bot cree que ha recibido una respuesta legítima y sigue adelante sin intentar de nuevo. Es una defensa silenciosa que no genera tráfico adicional ni delata su existencia.
El escaneo de URLs tiene una capa de validación que va más allá de comprobar que la URL es sintácticamente correcta. Antes de enviar cualquier URL a urlscan.io, el servidor verifica que usa protocolo HTTP o HTTPS, que no apunta a rangos de IP privados (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) y que no supera los 2000 caracteres de longitud.
Esto previene ataques de tipo SSRF (Server-Side Request Forgery) en los que alguien podría intentar usar el servicio como rebote para escanear redes internas. La validación también bloquea variantes de direcciones de loopback que herramientas automatizadas usaban para saltarse la protección contra rangos privados. Si un usuario envía http://192.168.1.1/admin, el servidor lo rechaza antes de que llegue a urlscan.
Cuando alguien envía una URL acortada (bit.ly, tinyurl y similares), urlscan.io tiene que seguir toda la cadena de redirecciones internamente, lo que a veces excede el timeout de polling del frontend. Para evitarlo, el servidor resuelve las URLs acortadas antes de enviarlas al escáner. Sigue los redirects con validación SSRF en cada salto para que la resolución no se convierta en un vector de ataque, y cachea tanto la URL original como la resuelta para que consultas repetidas sean inmediatas.
Muchas estafas llegan como capturas de pantalla reenviadas por WhatsApp, donde las URLs están incrustadas en la imagen y no se pueden copiar. ScamDetector resuelve esto con un pipeline de OCR que extrae automáticamente las URLs visibles en las imágenes que subes.
Cuando subes capturas de pantalla, el frontend las envía a un endpoint dedicado del servidor. El servidor las reenvía a Gemini Flash (a través del gateway configurado) como tarea de reconocimiento óptico, identifica cualquier URL visible en el texto de la captura y las devuelve como una lista. El frontend recibe esas URLs y las ofrece directamente para escanear con urlscan.io, sin que el usuario tenga que transcribirlas a mano.
Es un detalle de usabilidad con impacto real. La diferencia entre detectar una estafa y caer en ella puede ser tan simple como no tener que escribir a mano una URL de una captura para poder analizarla.
Cuando urlscan.io escanea una URL, genera una captura de pantalla de la página visitada. Mostrar esa captura directamente en el navegador del usuario provocaba errores de CORS, así que el servidor la proxifica a través de un endpoint propio.
El proxy no se limita a retransmitir la imagen. urlscan.io tarda unos segundos en generar la captura real, y mientras tanto devuelve una imagen placeholder genérica. El servidor detecta estos placeholders por su tamaño característico y responde con un código 202, lo que indica al frontend que debe reintentar. El frontend reintenta automáticamente con backoff hasta obtener la captura definitiva.
Las capturas recuperadas se envían además como contexto visual al modelo de Gemini para que analice la apariencia de la página. Esto permite detectar clones visuales de bancos o tiendas que serían difíciles de identificar solo con datos técnicos como certificados o redirecciones.
El prompt del sistema que recibe Gemini tiene más de 1500 palabras y está diseñado específicamente para el mercado español. No es un prompt genérico de "detecta estafas" traducido al castellano.
Incluye indicadores de urgencia artificial como el "su cuenta será bloqueada en 24 horas" que usan casi todas las estafas, patrones de suplantación de las cinco grandes entidades bancarias españolas, técnicas de phishing de paquetería falsa de Correos y SEUR, esquemas de suplantación de compañías energéticas y de Hacienda, y tipos de fraude particularmente frecuentes en España como el Bizum inverso o el mensaje del "hijo en apuros".
Cuando el análisis se enriquece con datos de urlscan, el prompt incluye reglas explícitas para interpretar esa información. Un sitio marcado como malicioso por urlscan sube automáticamente a riesgo alto. Un certificado TLS emitido hace menos de 30 días en un sitio que dice ser un banco es un indicador fuerte de phishing. Múltiples redirecciones antes de llegar al destino final son sospechosas. Estas reglas están codificadas en el prompt, no inferidas por el modelo.
El frontend de ScamDetector es HTML, CSS y JavaScript puro. Sin React, sin Vue, sin build step, sin bundler. Un fichero HTML, un fichero CSS y un fichero JavaScript de más de 1500 líneas que gestiona todo el estado de la aplicación.
Puede parecer una decisión anacrónica, pero tiene sentido para este tipo de herramienta. El resultado es una página que carga en milisegundos, que funciona en cualquier navegador moderno sin transpilación, y que cualquier persona puede auditar abriendo View Source. Para un proyecto educativo cuyo objetivo es inspeccionar mensajes sospechosos, la transparencia del código es una virtud.
El CSS usa un estilo neubrutalism con soporte completo para modo oscuro mediante variables CSS y respeto a prefers-color-scheme. También respeta prefers-reduced-motion, desactivando animaciones decorativas y el scroll suave para usuarios que lo prefieran. La persistencia del tema elegido usa localStorage, igual que el historial de análisis.
Un service worker registrado al cargar la página implementa una estrategia de cache híbrida. Los assets estáticos (JavaScript, CSS, iconos) usan cache-first para que la interfaz cargue aunque no haya conexión. Las peticiones a /api/* van siempre a red, sin pasar por cache. Y las navegaciones usan network-first con fallback a cache. Combinado con el manifiesto PWA, esto permite instalar ScamDetector como aplicación en el móvil y que funcione de forma razonable sin conexión para consultar el historial local.
ScamDetector se despliega en producción sobre Dokploy con un Dockerfile multi-stage basado en Alpine. La primera etapa instala esbuild y minifica los tres ficheros del frontend (JavaScript, CSS y el service worker), reduciendo el tamaño de los assets en torno a un 35%. La segunda etapa instala solo las dependencias de producción con --ignore-scripts, copia los ficheros minificados encima de los originales, ejecuta el proceso con un usuario sin privilegios de root y comprueba la salud del servidor con un healthcheck contra 127.0.0.1:3000. Para quienes prefieran serverless, también hay compatibilidad con Vercel mediante un fichero de configuración que establece un timeout extendido de 60 segundos para las llamadas a la IA.
El servidor gestiona el apagado de forma ordenada. Al recibir SIGTERM (el señal que envía Dokploy al redesplegar), deja de aceptar peticiones nuevas (devuelve 503 a las que lleguen), espera a que terminen las que están en vuelo y solo entonces cierra el proceso. Si después de 10 segundos quedan peticiones sin completar, fuerza el cierre. Esto evita respuestas cortadas a medias cuando se redespliega.
Las variables de entorno son mínimas. Dependiendo del gateway elegido, necesitas la clave de OpenRouter o la clave de Vercel AI Gateway, más la clave de urlscan.io, las claves de Cloudflare Turnstile (pública y privada) y opcionalmente el origen permitido para CORS en producción.
Todos los endpoints (análisis, escaneo de URLs y extracción de URLs de imágenes) escriben un log estructurado en formato JSONL con cada operación procesada. El fichero tiene un límite de 5 MB con rotación automática y se purga a los 7 días. Cada entrada registra el hash de la IP (nunca la IP en claro), el hash del teléfono si se consultó, el número de imágenes (sin las imágenes), el gateway usado, el endpoint de origen, la duración de la petición y el resultado. La idea es poder entender patrones de uso reales sin comprometer la privacidad de nadie.
En producción, el servidor aplica un conjunto estricto de cabeceras de seguridad. Content Security Policy limita los orígenes permitidos para scripts, estilos e imágenes. X-Frame-Options en DENY impide que la página se cargue en un iframe. X-Content-Type-Options desactiva el MIME sniffing. Referrer-Policy solo envía el origen sin la ruta completa. Y Permissions-Policy bloquea el acceso a cámara, micrófono y geolocalización que la herramienta no necesita.
Son medidas estándar, pero no aplicarlas sería negligente en una herramienta que recibe datos potencialmente sensibles como mensajes de texto y números de teléfono.
ScamDetector es JavaScript vanilla sin framework ni build step, y apliqué la misma filosofía a los tests. El runner nativo de Node.js (node:test) cubre todo sin añadir Jest, Vitest ni ninguna otra dependencia al proyecto.
Los tests unitarios verifican las funciones puras que sostienen la seguridad: normalización de respuestas, validación SSRF, detección de inyecciones de prompt, sanitización Unicode, rate limiting y gestión de sesiones. Los tests de integración prueban los cuatro handlers HTTP (analyze, verify, extract-urls, urlscan) con mocks de las APIs externas para validar el flujo completo sin consumir créditos. Y los tests end-to-end contra APIs reales se ejecutan manualmente cuando hace falta verificar la integración con los proveedores.
Las funciones internas se exponen mediante module.exports._internal para poder testearlas sin modificar la API pública. Es un compromiso pragmático: el sufijo _internal deja claro que no es una interfaz estable, pero evita tener que rediseñar módulos solo para hacerlos testeables.
ScamDetector es un proyecto personal y educativo, pero eso no significa que tenga que estar mal hecho. El proxy server-side, el rate limiting con privacidad, la validación estricta de entradas, el honeypot anti-bot y las cabeceras de seguridad son medidas que cualquier aplicación web debería tener, independientemente de su escala.
Los dos backends de IA comparten prompts y normalización de respuestas, así que el coste real de mantenerlos es bajo. La abstracción se justifica porque protege la parte del código que más cambia (los modelos de IA) de la que menos debería cambiar (la seguridad y la experiencia de usuario).
Desde que se publicó este artículo, la arquitectura se ha reforzado con capas adicionales de protección: verificación anti-bot con Cloudflare Turnstile, detección de inyección de prompts, sanitización Unicode y penalizaciones progresivas en el rate limiting. Todo eso está documentado en el artículo de endurecimiento.
El código es público y cualquier persona puede auditarlo, desplegarlo en su propia infraestructura o adaptarlo para su contexto. Si te interesa la implementación técnica de alguna de las piezas que hemos visto, el repositorio incluye documentación detallada de la arquitectura, las variables de entorno y los pasos para levantar el entorno de desarrollo.

Cuatro cambios para que la privacidad de ScamDetector deje de ser un banner y esté en el código. Routing forzado a Google Vertex con Zero Data Retention, selector de envío con ofuscación server-side, política RGPD reescrita desde cero y fuentes autohospedadas para no filtrar la IP del usuario a Google.

Añadí notificaciones push al móvil para ScamDetector con ntfy self-hosted. Cuatro alertas en tiempo real (inyección, ban, brute-force, backend caído) y tres monitores periódicos (digest diario, uso de disco, pico de tráfico). Un módulo de 90 líneas sin dependencias.

Qué cambió en ScamDetector después de publicar. Tercer backend de IA, rate limiting persistente, logging con privacidad, pasos de acción por tipo de estafa, compartir resultados como imagen y PWA.