QR code phishing: how quishing works, and how to stop it before the link opens
A QR code is a link nobody can read. That's the whole attack. Here is how criminals use it, why it slips past the usual defenses, and how to put a check between the scan and the open.
SkanQRCode team · · 8 min read
Mehmet Akif's write-up of real-world quishing walks through how QR codes are read and how attackers swap them in restaurants, at ATMs and on posters. It's a good map of the problem. This guide starts from the same cases and goes one step further: for each problem, what an app that decodes QR codes can actually do about it.
What happens when a phone reads a QR code
The three large squares in the corners tell the camera where the code is and which way up it sits. The decoder then reads the grid of modules, using built-in error correction to survive smudges and odd angles, and hands back plain text, usually a URL.
That's where the decoder's job ends. Whatever happens next is up to the app: most open the link at once or show a tiny preview that people tap through. That moment, between decode and open, is the security boundary, and in most apps nothing sits there.
How attackers use QR codes
Swapped codes in public places. A printed sticker over the real code on a restaurant table, a menu, an ATM, an advert or a lamp post. Parking meters and EV chargers are the same play. People assume a code in a familiar place is the business's own, and a sticker takes seconds to apply.
Codes in email and PDFs. A link inside an image is not a link to most email filters. "Scan to review the document" moves the click from a monitored laptop to a personal phone.
Where the codes lead. Fake login pages for well-known brands, fake payment gateways that take card details, and pages that push a malicious app or file download.
Why quishing gets through
Nobody can see the destination. A human can't read a QR code, so the usual advice, "check the link before you click", doesn't apply.
Phones sit outside corporate filtering. The scan happens on a personal device, often on mobile data, away from the web proxy and email gateway.
Short links hide where you're going. Even a careful person sees only the shortener's domain.
Phishing sites live for hours, not weeks. One large study puts the median phishing URL's lifetime at 5.5 hours, and campaigns typically run their course within a day. By the time a site reaches a blocklist, the campaign has often moved on (Lee et al., 2025; Oest et al., 2020).
Lookalikes and trusted platforms. paypa1.com reads as PayPal at a glance, and a phishing form on a well-known form builder inherits that platform's reputation.
Defenses
How SkanQRCode handles each problem
One API call between decode and open. The reason codes are the ones you'll see in the response, so your app can explain a warning, not just show it.
A sticker over the real code sends people to a phishing page
Check every decoded URL before it opens. Known phishing and malware URLs come back as block.
KNOWN_PHISHING_MATCH
ACTIVE_THREAT_FEED_MATCH
Nobody can see where a QR code goes before scanning it
Your app gets a verdict and an action (allow, warn or block) before openURL, so it can show the real destination on a warning instead of opening it blind.
Short links hide the real destination
We follow up to three redirects with lightweight requests, check where the link really lands, and return that as finalUrl. We never load the page.
URL_SHORTENER_REDIRECT
UNRESOLVED_REDIRECT
Brand-new phishing domains aren't on any blocklist yet
Heuristics that need no blocklist: brand lookalikes, mixed-script hostnames, a brand hidden before an @, random-looking names, high-risk TLDs and login wording on unencrypted pages.
BRAND_LOOKALIKE_DOMAIN
MIXED_SCRIPT_DOMAIN
DECEPTIVE_URL_USERINFO
RANDOMIZED_DOMAIN_NAME
HIGH_RISK_TLD
CREDENTIAL_LURE_KEYWORDS
INSECURE_CREDENTIAL_PAGE
Phishing pages hosted on trusted platforms (form builders, file sharing)
Recognizes forms and files on shared platforms instead of trusting the platform's good name, and flags executable downloads.
HOSTED_FORM_ON_SHARED_PLATFORM
FILE_SHARING_HOST
EXECUTABLE_PAYLOAD
Fake payment gateways and malware on bad infrastructure
Flags raw IP addresses as hosts and hosts that resolve to infrastructure already tied to malicious sites.
IP_ADDRESS_HOST
HOSTED_ON_MALICIOUS_IP
MALICIOUS_HOSTING_NEIGHBORHOOD
Your organization knows which destinations are legitimate
Block lists on every plan; allow lists on Pro and above. A parking operator can allow its one payment domain and block known abuse.
BLOCK_LIST_MATCH
ALLOW_LIST_MATCH
Phones and inboxes sit outside corporate web filtering
The check runs where the code is decoded: your scanner app, an MDM-filtered camera, an email pipeline that decodes QR images in attachments, or an AI agent via MCP.
What a URL check can't stop
Most quishing ends at a web page, and that's where we help. Some of it doesn't, and it's better you hear that from us.
A real payment provider, the wrong account
If a swapped code points to a legitimate payment service, just with the attacker's account in it, the domain is genuine. No URL check can tell whose account a payment link belongs to. Payment apps that show the merchant name before confirming, and staff who check the codes, are the defense there.
Codes that aren't web links
Wi-Fi join codes, SMS, phone numbers and payment schemes other than http/https are not evaluated. They come back as suspicious with UNSUPPORTED_SCHEME so your app can decide what to do, never as a silent allow.
The sticker itself
We see the text inside the code, not the poster it's stuck on. Tamper-evident printing and regular physical checks still matter for anyone who puts QR codes in public places.
Perfect detection
Like every URL classifier, ours can miss a well-built page or flag a harmless one. That's why the response has a warn action between allow and block, and why reason codes tell you what we saw.
Wire it in
Decode on the device, so the photo never leaves the phone. Send only the decoded text, then act on the answer. The fast path answers in under 500 ms.
const decoded = await scanner.decode(frame); // ML Kit, ZXing, AVFoundation
const res = await checkUrl(decoded); // POST /v1/check
if (res.action === "block") {
showBlocked(res.reasons);
} else if (res.action === "warn") {
confirmBeforeOpening(res.finalUrl ?? decoded); // show the real destination
} else {
openURL(decoded);
}
Decide up front what your app does if the check can't be reached: showing the destination and asking is a safer default than opening silently. Building an AI agent instead? The same check is available as an MCP server.
What happens to the URLs you send
The SkanQRCode API never logs the URLs you check, and we never sell your data. We keep a one-way hash with the verdict for at most 24 hours, and for short links the resolved destination for up to 24 hours. Full details.
Put a check between the scan and the open
The free sandbox gives you 1,000 checks a month with the same engine and no card. When you ship, the first 200 teams get Pro for $9.95/mo, for good.