ScamDetector's architecture, an AI proxy that doesn't expose secrets
How ScamDetector is built under the hood, from the server-side proxy that protects API keys to the three interchangeable AI backends and the security measures.

When you build a tool that uses AI models in production, one of the first decisions is where to put the access keys. If you put them in browser JavaScript, anyone can open the dev tools and copy them. If you use an intermediate server, you have to set up infrastructure that a lot of educational projects skip because "it's just a demo." With ScamDetector, I wanted to do things properly from the start, and that meant a server-side proxy that didn't expose any secrets to the browser.
In the previous article I covered what ScamDetector does and how it works from the user's point of view. This article goes under the hood, into the architecture decisions that make it work safely and flexibly.
The proxy pattern and why it matters
ScamDetector is a single-page application (HTML, CSS, and vanilla JavaScript, no frameworks) that talks to a Node.js server. The server acts as a proxy between the browser and external AI services. The browser never talks directly to Google Gemini, Perplexity, or urlscan.io.
That has three advantages that make the extra work worth it. First, no API key ever reaches the client. Second, the server can apply rate limiting, validation, and sanitization before forwarding the request. Third, the server normalizes responses so the frontend always works with the same JSON format, regardless of which AI backend is answering underneath.
Two interchangeable AI backends
ScamDetector supports two completely different AI backends, selectable with a single environment variable (AI_GATEWAY). It sounds like overengineering, but there's a practical reason for it.
The first path calls OpenRouter directly from the Node.js server, with no middle layer. Just a simple fetch() against the OpenRouter API using the same models (Gemini for messages, Perplexity Sonar for phone numbers), automatic retries with exponential backoff for 429 and 5xx errors, and a fallback to Relace Search if Perplexity doesn't respond. That's the main path, and the lightest one.
The second uses Vercel AI Gateway with its own SDK. The code imports the module as ESM and calls the models through Vercel's abstraction layer, with a different fallback that combines Gemini with a web search tool built into the SDK.
Both paths share two pieces that make maintaining them side by side viable. A prompts.js file centralizes all system prompts (message analysis, image analysis, phone reputation, URL extraction), so the content sent to the models is identical regardless of the gateway. And a shared normalizeResponse() function validates types, truncates strings to the maximum limits, and filters invalid values before returning the response to the frontend. The frontend doesn't know and doesn't need to know which backend is answering.
Why have two paths when one would do? Because they cover different needs. Direct OpenRouter is the option for self-hosted deployments, a clean fetch() with no outside dependencies. Vercel AI Gateway covers the case where someone prefers serverless or wants the built-in observability from its SDK. There was a third path using n8n as a visual orchestrator, but I removed it because it added a heavy dependency that didn't bring enough compared to the direct call. Thanks to the shared prompts and normalizeResponse(), the cost of maintaining two backends is minimal.
One model for each task
ScamDetector doesn't use a single AI model for everything. It uses the right model for each kind of query.
For message and screenshot analysis it uses Gemini 3 Flash. It's fast, cheap, and supports multimodal input (text and images in the same request). With a temperature of 0.2 and a limit of 1500 output tokens, it generates concise, deterministic responses in under five seconds.
For phone reputation lookups it uses Perplexity Sonar, a model built specifically for internet searches with context. The difference compared to using Gemini here is that Perplexity has access to real-time indexed data. When you ask about a phone number, it actively searches ListaSpam, Tellows, and other Spanish spam report sources.
If the message includes both text and a phone number, both queries run in parallel with Promise.all. The user doesn't have to wait for one to finish before the other starts.
Rate limiting with privacy
ScamDetector's rate limiting applies a limit of 10 requests every 10 minutes per IP, with separate buckets for message analysis and URL scanning. What makes the implementation different is how it identifies users.
Instead of storing IP addresses in plain text, it hashes them with SHA-256 and a random salt before saving them. The salt is generated automatically on first startup (32 random bytes), persisted in /app/data/hash-salt.txt with 600 permissions, and reused on later restarts. The result is that the persistence file contains salted hashes, not IPs. The hashes stay consistent across restarts (same salt), but they can't be compared across different installations. That makes it possible to apply per-user limits without creating a record of who used the service or when.
Alongside the per-IP limit there's also a global rate limit of 100 requests every 10 minutes across all IPs combined. This protects against IP rotation attacks, where someone uses a VPN or a botnet to spread requests across many addresses and bypass the individual limit. If the per-IP limit is exceeded, the server returns a 429. If the global limit is exceeded, it returns a 503. That distinction matters because the frontend shows different messages in each case.
The response headers report the rate limit status, and the frontend shows a visual countdown when you're getting close to the limit. Both the per-IP counters and the global counter are persisted to JSON files on disk, so restarting the container doesn't reset them. In Dokploy, the /app/data/ directory is mounted as a named volume so the data survives redeploys.
To identify the real IP behind proxies and CDNs, the server checks the Cloudflare header first, then X-Forwarded-For, and finally the direct connection IP. A small detail, but necessary if you want the limit to work properly in production behind a reverse proxy.
The invisible honeypot
The ScamDetector form includes two hidden fields that a human user will never see or fill in. They're email and full name fields marked with aria-hidden="true", tabindex="-1", and autocomplete="off", invisible both visually and to screen readers.
Bots that autofill forms complete them because they don't distinguish between visible and invisible fields. When the server detects that either of these fields has content, it returns an HTTP 200 response with a fake low-risk result instead of an error. The bot thinks it got a legitimate response and moves on without retrying. It's a quiet defense that doesn't generate extra traffic or reveal that it exists.
Strict URL validation
URL scanning has a validation layer that goes beyond checking whether the URL is syntactically valid. Before sending any URL to urlscan.io, the server verifies that it uses HTTP or HTTPS, that it doesn't point to private IP ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), and that it doesn't exceed 2000 characters in length.
This prevents SSRF (Server-Side Request Forgery) attacks where someone might try to use the service as a bounce point to scan internal networks. The validation also blocks loopback address variants that automated tools were using to get around the protection against private ranges. If a user sends http://192.168.1.1/admin, the server rejects it before it ever reaches urlscan.
When someone submits a shortened URL (bit.ly, tinyurl, and similar), urlscan.io has to follow the entire redirect chain internally, which sometimes exceeds the frontend polling timeout. To avoid that, the server resolves shortened URLs before sending them to the scanner. It follows redirects with SSRF validation at every hop so the resolution step doesn't become an attack vector, and it caches both the original URL and the resolved one so repeated lookups are immediate.
Extracting URLs from images
A lot of scams arrive as screenshots forwarded over WhatsApp, where the URLs are embedded in the image and can't be copied. ScamDetector solves that with an OCR pipeline that automatically extracts visible URLs from the images you upload.
When you upload screenshots, the frontend sends them to a dedicated server endpoint. The server forwards them to Gemini Flash (through the configured gateway) as an optical recognition task, identifies any URL visible in the screenshot text, and returns them as a list. The frontend receives those URLs and offers them directly for scanning with urlscan.io, without the user having to transcribe them by hand.
It's a usability detail with real impact. The difference between catching a scam and falling for it can be as simple as not having to type out a URL from a screenshot by hand just to analyze it.
Screenshot proxy and visual analysis
When urlscan.io scans a URL, it generates a screenshot of the visited page. Showing that screenshot directly in the user's browser was causing CORS errors, so the server proxies it through its own endpoint.
The proxy doesn't just relay the image. urlscan.io takes a few seconds to generate the real screenshot, and in the meantime it returns a generic placeholder image. The server detects these placeholders by their characteristic size and responds with a 202, which tells the frontend to retry. The frontend retries automatically with backoff until it gets the final screenshot.
The retrieved screenshots are also sent as visual context to the Gemini model so it can analyze the look of the page. That makes it possible to detect visual clones of banks or shops that would be hard to identify from technical data alone, like certificates or redirects.
Prompts tuned for the Spanish context
The system prompt Gemini receives is over 1500 words long and is designed specifically for the Spanish market. It's not a generic "detect scams" prompt translated into Spanish.
It includes indicators of fake urgency like "your account will be blocked in 24 hours," which almost every scam uses, impersonation patterns from Spain's five biggest banks, fake parcel phishing techniques involving Correos and SEUR, impersonation schemes targeting energy companies and Hacienda, and fraud types that are especially common in Spain, like reverse Bizum or the "child in trouble" message.
When the analysis is enriched with data from urlscan, the prompt includes explicit rules for interpreting that information. A site marked as malicious by urlscan automatically goes to high risk. A TLS certificate issued less than 30 days ago on a site claiming to be a bank is a strong phishing signal. Multiple redirects before reaching the final destination are suspicious. These rules are encoded in the prompt, not inferred by the model.
Frontend without dependencies
ScamDetector's frontend is plain HTML, CSS, and JavaScript. No React, no Vue, no build step, no bundler. One HTML file, one CSS file, and one JavaScript file of more than 1500 lines that manages the entire application state.
It may look like an anachronistic choice, but it makes sense for this kind of tool. The result is a page that loads in milliseconds, works in any modern browser without transpilation, and that anyone can audit by opening View Source. For an educational project whose goal is to inspect suspicious messages, code transparency is a virtue.
The CSS uses a neubrutalism style with full dark mode support via CSS variables and respect for prefers-color-scheme. It also respects prefers-reduced-motion, disabling decorative animations and smooth scrolling for users who prefer that. The selected theme is persisted with localStorage, same as the analysis history.
A service worker registered on page load implements a hybrid cache strategy. Static assets (JavaScript, CSS, icons) use cache-first so the interface loads even without a connection. Requests to /api/* always go to the network, never through cache. And navigations use network-first with cache fallback. Combined with the PWA manifest, this makes it possible to install ScamDetector as an app on your phone and have it work reasonably well offline for checking local history.
Deployment
ScamDetector is deployed to production on Dokploy with a multi-stage Dockerfile based on Alpine. The first stage installs esbuild and minifies the three frontend files (JavaScript, CSS, and the service worker), cutting asset size by around 35%. The second stage installs only production dependencies with --ignore-scripts, copies the minified files over the originals, runs the process as a non-root user, and checks server health with a healthcheck against 127.0.0.1:3000. For people who prefer serverless, there's also Vercel compatibility through a configuration file that sets an extended 60-second timeout for AI calls.
The server handles shutdown cleanly. When it receives SIGTERM (the signal Dokploy sends during a redeploy), it stops accepting new requests (returning 503 to any that arrive), waits for in-flight requests to finish, and only then exits. If there are still unfinished requests after 10 seconds, it forces shutdown. This avoids responses getting cut off halfway through a redeploy.
The environment variables are minimal. Depending on the chosen gateway, you need either the OpenRouter key or the Vercel AI Gateway key, plus the urlscan.io key, the Cloudflare Turnstile keys (public and private), and optionally the allowed CORS origin in production.
All endpoints (analysis, URL scanning, and URL extraction from images) write a structured log in JSONL format for each processed operation. The file is capped at 5 MB with automatic rotation and is purged after 7 days. Each entry records the IP hash (never the plain IP), the phone hash if one was queried, the number of images (not the images themselves), the gateway used, the source endpoint, request duration, and the result. The idea is to understand real usage patterns without compromising anyone's privacy.
Security headers
In production, the server applies a strict set of security headers. Content Security Policy limits the allowed origins for scripts, styles, and images. X-Frame-Options set to DENY stops the page from loading inside an iframe. X-Content-Type-Options disables MIME sniffing. Referrer-Policy sends only the origin, not the full path. And Permissions-Policy blocks access to the camera, microphone, and geolocation that the tool doesn't need.
These are standard measures, but not applying them would be negligent in a tool that receives potentially sensitive data like text messages and phone numbers.
More than 240 tests without adding dependencies
ScamDetector is vanilla JavaScript with no framework and no build step, and I applied the same philosophy to the tests. Node.js's native runner (node:test) covers everything without adding Jest, Vitest, or any other dependency to the project.
The unit tests verify the pure functions that underpin security: response normalization, SSRF validation, prompt injection detection, Unicode sanitization, rate limiting, and session handling. The integration tests cover the four HTTP handlers (analyze, verify, extract-urls, urlscan) with mocked external APIs to validate the full flow without burning credits. And the end-to-end tests against real APIs are run manually when I need to verify provider integration.
Internal functions are exposed through module.exports._internal so they can be tested without changing the public API. It's a pragmatic trade-off. The _internal suffix makes it clear it's not a stable interface, but it avoids having to redesign modules just to make them testable.
Production standards in an educational project
ScamDetector is a personal and educational project, but that doesn't mean it has to be poorly built. The server-side proxy, privacy-friendly rate limiting, strict input validation, the anti-bot honeypot, and the security headers are measures any web app should have, regardless of scale.
The two AI backends share prompts and response normalization, so the real maintenance cost is low. The abstraction makes sense because it protects the part of the code that changes the most (AI models) from the part that should change the least (security and user experience).
Since this article was published, the architecture has been strengthened with additional protection layers: anti-bot verification with Cloudflare Turnstile, prompt injection detection, Unicode sanitization, and progressive penalties in rate limiting. All of that is documented in the hardening article.
The code is public and anyone can audit it, deploy it on their own infrastructure, or adapt it to their own context. If you're interested in the technical implementation of any of the pieces we've looked at, the repository includes detailed documentation on the architecture, environment variables, and the steps to bring up the development environment.
Another entry in the ScamDetector project series. Coming from Why I built ScamDetector and continuing with Iterating after publishing.

Jose, author of the blog
QA Engineer. I write out loud about automation, AI and software architecture. If something here helped you, write to me and tell me about it.
Leave the first comment
What did you think? What would you add? Every comment sharpens the next post.
If you liked this

Privacidad al máximo en ScamDetector, Vertex con ZDR y modo ofuscado
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.

Alertas push en el móvil con ntfy self-hosted
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.

Iterando sobre ScamDetector, lo que cambié después de publicar
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.