Maximum privacy in ScamDetector, Vertex with ZDR and obfuscated mode
Four changes to make ScamDetector's privacy story something that's in the code, not just a banner. Forced routing to Google Vertex with Zero Data Retention, a send selector with server-side obfuscation, a GDPR policy rewritten from scratch, and self-hosted fonts so the user's IP doesn't leak to Google.

A tool that asks you to paste suspicious messages has a fundamental problem. Suspicious messages often contain personal data, real IBANs, DNI numbers, phone numbers, addresses, one-time codes. If the user stops for half a second to think "where does this end up?", you've already lost them. It doesn't matter how much other products brag about AI, the real barrier to entry is trust, and in 2026 trust is built with technical decisions you can verify, not with a banner saying "we care about your privacy".
After the security hardening and the push alerts via ntfy, I spent a whole block of time moving ScamDetector toward a privacy posture I could actually defend if someone asked me about it, no hand-waving. Four changes, from model routing all the way to which fonts the frontend loads. They all fit together.
The prompt can't go through AI Studio
ScamDetector calls Gemini through OpenRouter. By default, OpenRouter picks Gemini's underlying provider (AI Studio or Vertex AI) based on availability and price. That difference matters. AI Studio, under its standard tier terms, allows Google to use prompts to improve its models. Vertex AI, with Zero Data Retention enabled, retains nothing and doesn't use the data for training. Same model, two different contracts.
If the prompt can land in either one without control, I can't tell the user "your data isn't training anyone." The honest answer would be "depends on the day." So I locked down the routing with an explicit provider block on every request:
body: {
model: 'google/gemini-2.5-flash',
messages: [...],
provider: {
order: ['google-vertex'],
allow_fallbacks: false,
zdr: true,
},
}What matters is the combination of those three keys. order forces the preferred provider, allow_fallbacks: false stops OpenRouter from silently falling back to AI Studio if Vertex doesn't respond, and zdr: true requires the selected provider to have Zero Data Retention enabled. If Vertex is down, the user gets a 503 error with a clear message instead of a response that went somewhere it shouldn't have.
It's a deliberate trade-off between availability and privacy. I could've allowed fallback to AI Studio with a flag marking it in the logs, but that felt like backing away from what I'd promised. A tool that analyzes scam attempts can't have a privacy side door. I'd rather show a "try again in a minute" than an "oh, by the way, this message can be used for training."
The same block applies when extracting URLs from images. Phone reputation lookup was left out because Perplexity and Relace aren't on Vertex, and forcing ZDR there would break fallback. It's an explicit, documented trade-off, not an oversight. The numbers being queried are phone numbers that appear in suspicious messages, which in the worst case is information the scammer has already spread around.
An "obfuscated" mode the user chooses
Forcing Vertex+ZDR covers the contractual side, but it doesn't remove the psychological friction of pasting a real IBAN. Even if the data isn't retained technically, the user still reads "I'm about to send this to an AI." Some won't send it. Others manually delete the sensitive bits first, with the risk that manual obfuscation changes the shape of the message and leaves the model with less context than it needs.
The fix was to offer an explicit send mode with server-side obfuscation. I wrote sensitive-patterns.js, a UMD module that detects five categories using regexes tuned for Spanish, IBANs (24-character ES prefix), cards (13 to 19 digits with the Luhn algorithm), DNI and NIE, Spanish phone numbers with and without the +34 prefix, and emails. Detection runs in order and works on a copy of the text to avoid double matches. A Spanish IBAN starts with two letters and a pair of check digits, but its last eighteen digits can be mistaken for a card number if you search in parallel. Running the patterns sequentially and marking spans that have already been consumed keeps IBAN and card matches from stepping on each other.
function detectSensitive(text) {
let remaining = text;
const findings = [];
for (const { type, re } of PATTERNS) {
remaining = remaining.replace(re, (match) => {
findings.push({ type, match });
return '\0'.repeat(match.length); // hueco que los siguientes patrones ignoran
});
}
return findings;
}Obfuscation replaces the match with a marker like [IBAN], [TARJETA] or [DNI]. It preserves the structure of the message, commas, line breaks, the surrounding context, because what the model needs to spot a scam is almost never the exact number, but the pattern of "they're urgently asking for your banking details." With markers, the model still sees the request as it is and makes the same call. Just without the numbers.
On the backend, api/analyze.js reads fields.sendMode from the form. If it's 'obfuscated', it passes the message through obfuscateSensitive before sanitizing and escaping. If it's 'full' (or missing), the message goes through untouched. The structured log records which mode was used, which kinds of sensitive data were detected, and whether obfuscation actually ran. Being able to look at aggregate numbers on how many users choose obfuscation tells me whether the option has real uptake or whether people just trust the default.
A UI that only shows up when it makes sense
A permanent binary selector would just be visual noise. If the user only pastes text without sensitive data, there's nothing to obfuscate. If they only upload an image, text obfuscation doesn't apply. If they only look up a phone number, same thing. The selector should appear when there's something to choose, and disappear when there isn't.
The frontend logic evaluates the form state on every change. If the text contains any match from detectSensitive, or there's an attached image, or the phone field is filled in, a card appears with two radio buttons ("Full send" and "Obfuscated send"). If the text triggered the match, the card gets a prominent style with a "Mode: obfuscated" badge that lights up only when the choice has a real effect. If the only reasons are image or phone number, where obfuscation doesn't do anything, the card collapses into a compact neutral note explaining what it detected and what obfuscated mode does not do in that case.
The default is always 'full' and it isn't persisted across sessions. I didn't want a checkbox that remembered the user's preference because the decision depends on the specific message. One work email someone pastes might contain nothing sensitive, and another one pasted five minutes later might. Deciding each analysis on its own is a bit more annoying for a second and a lot safer the rest of the time. Accessibility is covered too, with aria-live on the title, aria-label on the badge, and keyboard handling for Enter and Space.
The privacy policy, rewritten from scratch
The original policy said the usual things, "we don't store personal data, IPs are hashed, the text is sent to the model." Technically correct, but generic. If someone with an audit mindset reads it, it's obvious all the sections required by GDPR are missing.
I rewrote the privacy modal following the criteria the AEPD uses in its public guidance. It now includes who the data controller is (with name and contact method), the legal basis for processing (explicit consent when submitting the form, Article 6.1.a of the GDPR), what happens to each field separately (message, image, phone number, URL), international transfers (the European Commission's Standard Contractual Clauses and Google's participation in the EU-US Data Privacy Framework), the rights of access, rectification, erasure, objection, restriction, and portability, and the path to the AEPD with its complaints URL.
One detail this forced me to revisit was how I described hashes. Before, it said "we don't store your IP, we store a hash." That's still identifying data according to the AEPD if the hash is reversible or allows re-identification. The right term is pseudonymization, which is what it actually is, and stating that the hashes are calculated with SHA-256 plus a persistent per-installation salt. Calling it pseudonymization is more honest and saves me a problem if someone asks me about it technically. The last updated date is visible at the bottom of the modal, and the dateModified in JSON-LD is synced on every text change.
Most users won't read any of this. But the two or three who do are exactly the ones most likely to recommend the tool to others, because they're the ones who know how to value the fact that it's there.
You can't say "no data leaves" and load Google Fonts
I caught this one myself while writing the policy. I was saying "no form data goes to third parties beyond the analysis model," and in the HTML <head> there was a preconnect to fonts.googleapis.com and a link to the CSS for five different fonts. Every user who loaded the page sent a request with their IP, the Referer, and the User-Agent headers to a Google service unrelated to the analysis. The user's IP was going to Google before they had even clicked a button. The contradiction was exact.
I downloaded the five fonts exactly as Google served them to my site. Orbitron in weights 700 and 900, Share Tech Mono 400, Space Grotesk in 400, 500, 600 and 700. They're small woff2 files, and together they weigh a bit more than an average image. I stored them in /fonts, replaced the external CSS with local @font-face rules using font-display: swap, and removed the external preconnect and link tags. The server's CSP got simpler too, down to font-src 'self' and style-src 'self', with no third-party exceptions.
The nice side effect is that the first load is faster. Without a round trip to Google, the fonts come down over the same already-warm HTML connection. And the service worker, which was already caching the app shell assets, now also caches the fonts and the sensitive-patterns.js module. I bumped the cache name (scamdetector-v10) to force a clean invalidation in browsers with the previous version.
Privacy as a feature, not a footnote
These four changes look unrelated if you look at them separately. A provider block in a request. A UI with two radio buttons. A text modal. Five woff2 files. But they're all pointed at the same thing. The user who lands on ScamDetector isn't asking whether the code is elegant or whether the tests pass, they're asking where their data ends up.
What this exercise showed me is that you answer that question in code, not on the privacy page. If my policy says ZDR and my provider doesn't force it, I'm lying. If my policy says "no data to third parties" and my HTML loads Google Fonts, I'm lying. And the lie isn't deliberate, it's just that most websites ship with so many default layers that if you don't sit down and review them one by one, you end up saying things that aren't true without realizing it.
Closing all those gaps at once took a lot less work than it looked like before I started. A good chunk of time for the provider and the tests, an afternoon for the obfuscation patterns and the UI, another good chunk for the policy and the fonts. Three or four sessions. The result is that I can now describe exactly what happens to each type of data, and the code backs that description up.
If you have a project that touches personal data, spending an afternoon asking yourself "what am I promising, and what am I actually doing?" will probably give you a handful of small tasks like these. It's the kind of work that doesn't look great in a README, but it's what separates a serious tool from a prototype with generous copy.
Try it at scamdetector.josemanuelortega.dev.
Another entry in the ScamDetector project series. You came from Push alerts with ntfy. To go back to the beginning, Why I built ScamDetector.

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

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.

La arquitectura de ScamDetector, un proxy de IA que no expone secretos
Cómo está construido ScamDetector por dentro, desde el proxy server-side que protege las claves de API hasta los tres backends de IA intercambiables y las medidas de seguridad.