
Real-Time QR Code Phishing Detection with JavaScript and the Safe Browsing API
The material I was given describes a travel alert about rising QR-code phishing in Miami, Dallas, Seattle, and Philadelphia, and says travelers should inspect codes before scanning them. I could not verify the original bulletin from the supplied text, so I treat the cities and the issuer as reported, not confirmed. The defensive takeaway still stands: if a scanner opens the destination before it checks the URL, the user has already lost the only useful checkpoint.
The reported QR-code phishing warning and why it matters
What the source reports about the travel alert
The supplied material says a U.S. warning described a surge in QR-code phishing and told travelers to inspect codes before scanning them. That is the only part I can treat as source-backed from what I was given.
What I cannot confirm from the provided context is:
- which agency issued the warning
- whether the cities listed were the full scope or just examples
- whether the alert was a formal advisory, a local notice, or a news summary of both
So I am keeping the reading narrow on purpose: the report points to a real phishing pattern, but the exact public-safety framing still needs verification from the original advisory or bulletin.
Why QR codes are a useful phishing channel
QR codes work well for phishing because they compress the trust decision into a tiny square that most people scan without reading. On a phone, the destination is often hidden behind a preview sheet or a webview, not a browser window with a visible hostname. That makes it easy to miss:
- lookalike domains
- short links
- encoded redirects
- non-HTTP schemes
- credential-capture pages disguised as payments or ticketing
Travel settings make this worse. People scan menus, parking meters, baggage notices, airport signs, hotel Wi-Fi cards, and ticket kiosks while distracted. The attacker does not need a perfect payload. They just need one fast scan at the wrong moment.
What I would treat as confirmed, and what still needs verification
Source-backed facts versus inference from the alert
| Claim | Status | Why |
|---|---|---|
| The source reports a travel alert about QR-code phishing in multiple U.S. cities | Reported | Present in the supplied source summary |
| Travelers are being told to inspect QR codes before scanning | Reported | Present in the supplied source summary |
| QR codes are a phishing vector | Confirmed generally | This is a known attack pattern, but not proven by the supplied report alone |
| The listed cities are the complete affected scope | Unconfirmed | The supplied context does not show the original advisory text |
| Travelers and mobile users face higher practical risk | Inference | Plausible because the scan flow is rushed and the destination is less visible |
What I confirmed from the material is limited. What I did not test is the specific alert, the issuing body, or the geographic scope. That matters because a sloppy write-up can turn a rumor into something that sounds official.
The practical risk for travelers and mobile users
The risk is not “QR codes are dangerous” in the abstract. The real problem is that scanners often optimize for convenience, not scrutiny.
If the app auto-opens a URL:
- the destination may load before the user notices the domain
- a login or payment page may already be visible
- the user is now reacting, not evaluating
That is the trust failure I care about. The scanner should own the decision, not just decode the payload and hand it to the browser.
Threat model for a JavaScript QR scanner
Where the trust boundary really sits
In a QR scanner, the trust boundary is not the pixel matrix. It is the handoff from decoded payload to navigation.
I would model the flow like this:
- untrusted input: QR payload text
- parsing layer: normalize and classify the payload
- reputation layer: check destination against known-bad signals
- policy layer: decide whether to open, warn, or block
A common mistake is to treat “decoded URL” as already safe enough to open. It is not. It is just text that looks like a destination.
One more thing I would not do: fetch arbitrary QR destinations from your backend to “see where they go.” That creates SSRF risk and can leak private scan targets into your infrastructure. For a scanner, the reputation check should operate on the parsed URL string, not by visiting the target.
What can be checked before a user opens the destination
Before navigation, you can check:
- scheme: allow only
http:andhttps: - hostname format: flag punycode, IP literals, and userinfo
- destination shape: unusually long paths,
@in the authority, or encoded redirects - reputation: Safe Browsing or another blocklist-backed service
- policy: domain allowlist for managed environments
That gives you a layered verdict instead of a blind open.
A real-time detection pipeline
Decode the QR payload and normalize the URL
My preferred pipeline is conservative:
- decode the QR payload into raw text
- parse it as a URL only if it is actually URL-like
- reject unsupported schemes immediately
- normalize the hostname and strip credentials and fragments
- score local red flags
- query a reputation API
- combine the signals into one user-facing verdict
Normalization matters because phishers lean on formatting tricks. A URL can look harmless in one form and suspicious in another. The scanner should display the canonical version it is actually going to open.
Submit the destination to the Safe Browsing API
Google’s Safe Browsing lookup API is useful here because it can tell you whether a URL is known for malware or social engineering. The key point is that it is a signal, not a guarantee.
I prefer to call it from a backend route rather than directly from the browser:
- the API key stays off the device
- you can rate-limit lookups
- you can centralize logging and policy
- you can return a single app-specific verdict to the client
Google documents the lookup shape in its Safe Browsing API docs. The scanner should treat a 204 No Content response as “no match” and a match response as a hard block or at least a strong warning.
Add local checks for lookalikes, redirects, and suspicious schemes
Local checks catch things reputation feeds miss:
javascript:anddata:should never be auto-openedfile:should be rejected in a web scanner- punycode like
xn--deserves a warning - userinfo like
user@hostis a classic deception trick - short links should be treated as opaque and risky
- login and payment paths deserve extra scrutiny
This is where I would take a firm position: if the scanner is meant for real users, it should not auto-open unknown URLs after a single decode step. It should show the destination first, then require confirmation.
Implementation sketch in JavaScript
Minimal browser flow from scan event to verdict
async function handleQrScan(rawPayload) {
const normalized = normalizeDestination(rawPayload);
const local = scoreLocalRisk(normalized);
const reputation = await lookupReputation(normalized);
const verdict = combineVerdicts(local, reputation);
return {
rawPayload,
normalized,
verdict,
reasons: [...local.reasons, ...reputation.reasons],
};
}
function normalizeDestination(rawPayload) {
const text = rawPayload.trim();
let url;
try {
url = new URL(text);
} catch {
throw new Error("QR payload is not a valid absolute URL");
}
if (!["http:", "https:"].includes(url.protocol)) {
throw new Error(`Unsupported scheme: ${url.protocol}`);
}
url.username = "";
url.password = "";
url.hash = "";
return url.toString();
}
function scoreLocalRisk(urlString) {
const url = new URL(urlString);
const reasons = [];
if (url.hostname.includes("xn--")) reasons.push("punycode hostname");
if (/^\d+\.\d+\.\d+\.\d+$/.test(url.hostname)) reasons.push("IP-literal host");
if (url.username || url.password) reasons.push("userinfo in authority");
if (url.pathname.length > 80) reasons.push("long path");
if (url.search.length > 120) reasons.push("long query string");
return {
risk: reasons.length >= 2 ? "suspicious" : "clean",
reasons,
};
}
async function lookupReputation(urlString) {
const res = await fetch("/api/reputation/lookup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: urlString }),
});
if (res.status === 204) {
return { risk: "clean", reasons: ["no Safe Browsing match"] };
}
if (!res.ok) {
return { risk: "suspicious", reasons: ["reputation lookup failed"] };
}
const data = await res.json();
return {
risk: "blocked",
reasons: data.matches.map((match) => `Safe Browsing match: ${match.threatType}`),
};
}
function combineVerdicts(local, reputation) {
if (reputation.risk === "blocked") return "blocked";
if (local.risk === "suspicious" || reputation.risk === "suspicious") return "suspicious";
return "clean";
}Example code for Safe Browsing lookup and result handling
export async function POST(req) {
const { url } = await req.json();
const body = {
client: {
clientId: "qr-scanner",
clientVersion: "1.0.0",
},
threatInfo: {
threatTypes: ["MALWARE", "SOCIAL_ENGINEERING", "UNWANTED_SOFTWARE"],
platformTypes: ["ANY_PLATFORM"],
threatEntryTypes: ["URL"],
threatEntries: [{ url }],
},
};
const upstream = await fetch(
"https://safebrowsing.googleapis.com/v4/threatMatches:find?key=" + process.env.SAFE_BROWSING_KEY,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}
);
if (upstream.status === 204) {
return Response.json({ verdict: "clean", matches: [] }, { status: 204 });
}
if (!upstream.ok) {
return Response.json(
{ verdict: "unknown", matches: [], error: "lookup failed" },
{ status: 502 }
);
}
const data = await upstream.json();
return Response.json(
{
verdict: "blocked",
matches: data.matches ?? [],
},
{ status: 200 }
);
}Sample outputs for clean, suspicious, and blocked URLs
These are the shapes I would expect the UI to render:
{
"verdict": "clean",
"normalized": "https://example.org/tickets",
"reasons": ["no Safe Browsing match"]
}
{
"verdict": "suspicious",
"normalized": "https://xn--pple-43d.com/login",
"reasons": ["punycode hostname", "long query string"]
}
{
"verdict": "blocked",
"normalized": "https://example.com/pay",
"reasons": ["Safe Browsing match: SOCIAL_ENGINEERING"]
}
The main point is not the exact wording. It is that the user sees the normalized destination and the reason before the browser leaves the scanner.
Testing the workflow safely
Benign test cases, known bad samples, and edge cases
I would start with safe, synthetic samples:
https://example.org/https://example.org/loginhttp://192.0.2.10/payhttps://xn--pple-43d.com/data:text/html,hellojavascript:alert(1)
Do not use live phishing URLs for routine testing. You do not need real malicious payloads to validate parsing, blocking, and UI behavior.
The edge cases worth checking are:
- uppercase and mixed-case schemes
- URLs with embedded credentials
- percent-encoded characters in the path and query
- QR payloads that are plain text instead of URLs
- scanner timeouts while the reputation lookup is still pending
Measuring latency, false positives, and user experience
For a real scanner, latency matters because users will abandon anything that feels sluggish.
My practical target would be:
- local parsing: near-instant
- reputation lookup: under a few hundred milliseconds when possible
- timeout behavior: warn and hold the URL, do not auto-open
False positives matter too. Shorteners, enterprise SSO links, and campus redirectors can all look suspicious. That is another reason to avoid a binary “good/bad” UI. Let the scanner show confidence and explain why the destination is being questioned.
Where Safe Browsing helps, and where it does not
Short links, redirect chains, and brand impersonation
Safe Browsing helps most when the destination is already known bad. It helps less when:
- the attacker uses a fresh domain
- the page is hosted on an otherwise clean domain
- the QR code points to a short link that later redirects
- the page is only a credential collector and not obviously malware
Brand impersonation is especially annoying. A clean-looking infrastructure domain can still host a fake airline or payment page. A reputation API will not always save you from that.
Stale reputation data, offline scans, and encoded payloads
Reputation feeds are not omniscient. They can lag behind a new campaign, and they obviously do nothing when the device is offline.
Encoded payloads are another weak spot. If your scanner only checks the first URL and never shows the final destination, a redirect chain can hide the real target. That is why I treat Safe Browsing as one signal, not the whole defense.
Defense in depth for QR-based phishing
Server-side checks, logging, and policy enforcement
If you are building this for a company, travel app, or managed mobile workflow, add server-side policy:
- allowlist known destinations for sensitive actions
- log normalized hostnames, verdicts, and lookup latency
- alert on repeated scans to new or suspicious domains
- keep raw scan data only as long as policy requires
I would also separate the scanner UI from the action layer. A scan can be informational without being a navigation event.
User warnings, education, and safer scan defaults
My default recommendation is simple: do not auto-open unknown QR destinations.
Safer defaults look like this:
- show the full domain before opening
- highlight punycode and short links
- require a second tap for first-time destinations
- block non-web schemes outright
- prefer copy-over-open when the context is ambiguous
In travel settings, a short warning beats a long explanation. Users need one clear rule: inspect the destination before you leave the scanner.
Conclusion: use the API as one signal, not the whole defense
The source report is a reminder that QR phishing is still a live problem, especially in high-distraction environments. I would not build a QR scanner that treats decode as trust.
Safe Browsing is valuable, and I would absolutely wire it into a scanner pipeline. But the real fix is structural: parse first, normalize carefully, score locally, check reputation, and make the user confirm before navigation. If you do that, the scanner becomes a defense layer instead of a fast path to the attacker’s page.


