Python 3 and a terminal. No QR code is scanned, generated or opened, and nothing
connects to the internet. macOS and Linux include Python; on Windows install it from
python.org with “Add python.exe to PATH” ticked, then check with
python3 --version.
You do not need a phone for any of this. Everything below works on the text a QR code contains, because that text is the whole of what a QR code is — the black-and-white pattern is only a way of printing it. Every address and network name used here is either reserved for documentation or invented.
A QR Code Is a Link You Cannot Read
Everything you have been taught about links stops working at a QR code. The standard advice is to inspect a link before you click it -- hover over it, read the domain, check for lookalike spellings. A QR code makes that impossible by design. It is a machine-readable pattern, and its destination is concealed until after your camera has already opened it.
Quishing -- QR code phishing -- exploits exactly that gap. The user has been trained to verify links and has been handed something they physically cannot verify, so they scan it and trust whatever appears. The technique also sidesteps a great deal of email filtering, because an image containing a code carries no URL text for a scanner to analyse.
The code is only an encoding, exactly like a shortened link. The danger is that it removes your ability to inspect the destination before committing -- so the defense is to restore that inspection step rather than to avoid QR codes entirely.
Where They Appear
- Sticker overlays in public -- a fraudulent code printed on adhesive paper and stuck over the real one on a parking meter, an EV charger, a bike-hire dock, or a restaurant table. Regulators and city authorities have issued repeated warnings about parking payment codes in particular
- Fake parking and toll notices -- a windscreen ticket or a text about an unpaid toll, with a QR code to "settle now"
- Package delivery cards -- a missed-delivery notice through your door with a code to rearrange, asking for a small redelivery fee and your card details
- Email attachments and images -- typically a fake multi-factor re-enrolment prompt: "scan to keep your account active". Aimed at moving you from a monitored work computer to an unmanaged personal phone
- Unexpected parcels you did not order -- containing a code to "see who sent this". The US Federal Trade Commission has warned about this as a variant of the brushing scam
- Crypto and investment posters -- codes leading to wallet-draining sites or fake giveaway pages
- Charity and donation appeals -- fraudulent codes placed near real collection points, often after a disaster
What Happens After the Scan
Three outcomes cover almost everything, and they escalate in seriousness.
You pay the "parking fee" to a criminal, and you have also handed over full card details for later use. The amount is deliberately small so it does not trigger suspicion or a bank alert.
A pixel-accurate copy of a login screen. Modern versions run as an adversary-in-the-middle proxy: they relay your login to the real service in real time, so your genuine MFA prompt appears and succeeds -- while the attacker captures both the password and the resulting session cookie. Google flagged this class of quishing attack in 2026.
An "app required to continue", a configuration profile, or a browser extension. On mobile this can mean a device-management profile that grants sweeping control. Nothing legitimate reached by scanning a sticker needs you to install a profile.
How to Scan Safely
Both iOS and Android show the destination as a banner or notification rather than opening it immediately. That preview is the inspection step the QR code took away -- actually read it instead of tapping through.
Read the part immediately before the first single slash. In
secure-payments.example-parking.io/city the actual domain is
example-parking.io, not secure-payments. Beware link shorteners,
which hide the destination a second time.
In the physical world this is the highest-value check available. Run a finger over the code: a raised edge, a bubble, a slight misalignment with the printed panel, or a code that partially covers surrounding text means someone put it there. Peel it if you can.
Use the operator's official app, type the address printed on the machine, or call the number on the signage. For parking specifically, an app you installed in advance removes the entire attack surface.
There is no situation where an organisation must communicate with you through an unreadable link inside a message. Log in to the service directly instead.
Anyone can obtain a certificate for a domain they control, so HTTPS means the connection is encrypted, not that the site is honest. Logos and layouts are copied perfectly. The domain name is the only part that is hard to fake.
Payment Habits That Limit the Damage
- Pay with a credit card or a phone wallet -- both give stronger dispute rights than a debit card, and a wallet shares a token rather than your real card number
- Use a virtual or single-use card number for anything unfamiliar, if your bank offers them
- Turn on transaction alerts -- small fraudulent test charges are how stolen card details get validated before larger ones follow
- Never pay by bank transfer, crypto or gift card for parking, deliveries or fines. No legitimate operator asks for these, and none of them are reversible
If You Have Already Scanned One
- Scanning alone is usually harmless -- if you opened a page and typed nothing, close the tab, clear the site data, and move on
- If you entered card details -- freeze or cancel the card in your banking app immediately, then dispute any charge
- If you entered a password -- change it from a different device, and crucially revoke all active sessions, because an adversary-in-the-middle page steals your session cookie as well as your password
- If you installed anything -- remove the app, the extension, or the configuration profile, and check the device's management and accessibility settings for entries you did not add
- Report it -- to the venue or operator so the sticker can be removed, and to your national fraud reporting service. Physical stickers stay in place until somebody says something
Read a QR Code Before You Obey It, in Five Steps
A QR code is a picture of a piece of text. That is the entire technology, and almost every problem with it follows from one consequence: you cannot read the text by looking at the picture. With a link on a web page you can hover and see where it goes; with a printed square you are asked to commit first and find out afterwards. In the next twenty minutes you will see exactly what QR codes contain, measure how much of a web address your phone can actually show you before you tap, take apart the payload that silently joins you to somebody's network, and finish with a short script that tells you what a code would do before you let it. Every line of output below came from running these files.
Go: open a terminal in a folder you can write to — cd ~/Desktop on macOS or Linux, cd %USERPROFILE%\Desktop on Windows.
Do: save this as payloads.py and run python3 payloads.py.
"""A QR code is a picture of a string. These are the strings phones act on."""
PAYLOADS = [
("a web address", "https://example.com/menu"),
("a wifi network", "WIFI:T:WPA;S:Cafe_Guest;P:latte2026;;"),
("a phone number", "tel:+448001234567"),
("a text message", "SMSTO:+447700900123:STOP"),
("an email", "mailto:orders@example.com?subject=Table%204"),
("a payment", "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzz?amount=0.05"),
("a contact card", "BEGIN:VCARD\\nFN:Delivery Co\\nTEL:+448001234567\\nEND:VCARD"),
]
print("%-18s %s" % ("WHAT IT IS", "WHAT THE QR ACTUALLY CONTAINS"))
print("-" * 78)
for label, text in PAYLOADS:
print("%-18s %s" % (label, text))
print()
print("A QR code carries no meaning of its own. The prefix before the first")
print("colon tells your phone which app to hand the rest to -- and some of")
print("those apps do something before you have read anything.")
You should see: seven kinds of instruction, all of which look like the same black square:
WHAT IT IS WHAT THE QR ACTUALLY CONTAINS
------------------------------------------------------------------------------
a web address https://example.com/menu
a wifi network WIFI:T:WPA;S:Cafe_Guest;P:latte2026;;
a phone number tel:+448001234567
a text message SMSTO:+447700900123:STOP
an email mailto:orders@example.com?subject=Table%204
a payment bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzz?amount=0.05
a contact card BEGIN:VCARD\nFN:Delivery Co\nTEL:+448001234567\nEND:VCARD
A QR code carries no meaning of its own. The prefix before the first
colon tells your phone which app to hand the rest to -- and some of
those apps do something before you have read anything.
These are not exotic formats; they are the ordinary ones every phone camera
understands. The important observation is in the last three lines: the text before the first
colon decides which app receives the rest. https: goes to the browser,
tel: to the dialler, WIFI: to the network settings — and the
amount of confirmation you get before that happens varies by phone, by app and by version.
If not: python3: command not found on Windows means Python was installed
without “Add python.exe to PATH”; try py payloads.py. The
\n inside the vCard line is deliberately shown as two characters — that is
what the payload contains.
Go: the same folder. This is the step that explains why QR phishing works at all.
Do: save this as preview.py and run python3 preview.py.
"""Your phone shows a preview. Measure how much of the address it can show."""
URL = ("https://secure-parking-payments.com.pay-portal.io/session"
"?ref=NCP4417&redirect=https%3A%2F%2Fncp.co.uk")
def registrable(url):
host = url.split("//", 1)[1].split("/", 1)[0]
return ".".join(host.split(".")[-2:])
for width in (28, 40, 60):
shown = URL[:width] + ("..." if len(URL) > width else "")
print("preview %2d chars: %s" % (width, shown))
print()
print("full length :", len(URL), "characters")
print("the host is :", URL.split("//", 1)[1].split("/", 1)[0])
print("the domain you reach :", registrable(URL))
print("first visible at char:", URL.index(registrable(URL)) + 1)
print()
print("The part that decides where you go sits past the end of a typical")
print("preview. What IS visible reads like a parking payment site.")
You should see: the deciding part of the address falling off the end of the preview:
preview 28 chars: https://secure-parking-payme...
preview 40 chars: https://secure-parking-payments.com.pay-...
preview 60 chars: https://secure-parking-payments.com.pay-portal.io/session?re...
full length : 102 characters
the host is : secure-parking-payments.com.pay-portal.io
the domain you reach : pay-portal.io
first visible at char: 37
The part that decides where you go sits past the end of a typical
preview. What IS visible reads like a parking payment site.
Read the three preview lines as a phone's notification banner, which is roughly
where a scanned address appears. At every width the visible portion says
secure-parking-payments.com, which is reassuring and entirely false: that is a
subdomain, and the domain you actually arrive at is pay-portal.io.
A web address is read right-to-left, not left-to-right. Everything before the final two labels is chosen freely by whoever owns those two labels. Attackers put the reassuring words at the front because that is where a truncated preview stops.
If not: if first visible at char reports a different number, the URL string was
altered when copying — it must be a single unbroken line, which is why the script joins two
quoted pieces rather than wrapping.
Go: the same folder.
Do: save this as wifi.py and run python3 wifi.py.
"""What a WIFI: payload asks your phone to do, field by field."""
PAYLOAD = "WIFI:T:WPA;S:Cafe_Guest;P:latte2026;H:false;;"
fields = {"T": "security type", "S": "network name", "P": "password", "H": "hidden network"}
body = PAYLOAD[len("WIFI:"):].rstrip(";")
print("payload:", PAYLOAD)
print()
for part in body.split(";"):
if ":" in part:
key, value = part.split(":", 1)
print(" %-2s %-16s %s" % (key, fields.get(key, "?"), value))
print()
print("Scanning this offers to join a network chosen by whoever printed the")
print("sticker. Once joined, that network sees every unencrypted request and")
print("answers every DNS question your phone asks.")
print()
print("It cannot read HTTPS traffic. It can see which sites you visit, and it")
print("can refuse to connect you to the real ones.")
You should see: the network name and its password supplied by the sticker:
payload: WIFI:T:WPA;S:Cafe_Guest;P:latte2026;H:false;;
T security type WPA
S network name Cafe_Guest
P password latte2026
H hidden network false
Scanning this offers to join a network chosen by whoever printed the
sticker. Once joined, that network sees every unencrypted request and
answers every DNS question your phone asks.
It cannot read HTTPS traffic. It can see which sites you visit, and it
can refuse to connect you to the real ones.
The last two lines are the honest limits, and they matter because this threat is routinely overstated. Someone running the network you joined cannot read your banking session; HTTPS holds, and every serious site uses it. What they can do is see which sites you contact, answer your DNS queries with addresses of their choosing, and refuse to connect you to anything they would rather you did not reach — then offer you a convincing “sign in to continue” page of their own.
So the practical rule is narrow and worth following: joining an unknown network is not a catastrophe, but never enter a password or payment detail while on one, and treat any login page that appears because you joined as hostile.
If not: if the loop prints nothing, the payload lost its semicolons — they separate the
fields and the format ends with two of them. A ValueError means a field arrived
without a colon, usually from a trailing space.
Go: the same folder.
Do: save this as payment.py and run python3 payment.py.
"""A payment QR fills in both boxes for you."""
PAYLOAD = "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzz?amount=0.05&label=Parking"
scheme, rest = PAYLOAD.split(":", 1)
address, _, query = rest.partition("?")
params = dict(p.split("=", 1) for p in query.split("&"))
print("scheme :", scheme)
print("address :", address)
print("amount :", params.get("amount"))
print("label :", params.get("label"))
print()
print("address length:", len(address), "characters")
print("how many of them you would notice if one changed:", 0)
print()
print("The recipient and the amount are both supplied by the code. Your job")
print("is reduced to pressing confirm, and the one field that decides where")
print("the money goes is %d characters you have no way to check." % len(address))
You should see: the recipient and the amount both pre-filled:
scheme : bitcoin
address : bc1qar0srrr7xfkvy5l643lydnw9re59gtzz
amount : 0.05
label : Parking
address length: 36 characters
how many of them you would notice if one changed: 0
The recipient and the amount are both supplied by the code. Your job
is reduced to pressing confirm, and the one field that decides where
the money goes is 36 characters you have no way to check.
This is the mechanism behind the parking-meter and restaurant-table sticker frauds, and it works because it removes every field a human might check. You are not asked who to pay or how much; you are asked to confirm. And the single field that determines the destination is a long random string, which people do not compare because there is nothing to compare it to.
The defence is physical before it is technical. A genuine parking or menu code is printed as part of the sign; a fraudulent one is a sticker applied over it. Run a fingernail along the edge. If it lifts, it was added.
If not: if amount prints None, the ? separating the
address from the parameters was lost, so partition returned an empty query. The
address length must print 36; a different number means characters were dropped.
Go: the same folder.
Do: save this as classify.py and run python3 classify.py. Every
phone now shows the decoded text before acting on it — this tells you what that text
means.
"""Paste in what your phone previewed, before you tap anything."""
import sys
# Suffixes that are two labels long. The full list (the Public Suffix List) has
# thousands of entries; these are enough to show why a naive rule gets it wrong.
TWO_LABEL_SUFFIXES = {"co.uk", "org.uk", "ac.uk", "gov.uk", "com.au", "co.nz",
"co.jp", "com.br", "co.za", "com.tr"}
def registrable(host):
parts = host.lower().strip(".").split(".")
if len(parts) >= 3 and ".".join(parts[-2:]) in TWO_LABEL_SUFFIXES:
return ".".join(parts[-3:])
return ".".join(parts[-2:])
def classify(text):
t = text.strip()
low = t.lower()
if low.startswith("wifi:"):
return "JOINS A NETWORK chosen by whoever printed this"
if low.split(":")[0] in ("bitcoin", "ethereum", "litecoin", "monero"):
return "SENDS MONEY to an address you cannot verify"
if low.startswith("smsto:") or low.startswith("sms:"):
return "SENDS A TEXT -- check the number is not premium rate"
if low.startswith("tel:"):
return "DIALS a number -- check it is not premium rate"
if low.startswith("begin:vcard"):
return "ADDS A CONTACT -- harmless, but the number in it may not be"
if low.startswith("http://"):
return "OPENS A SITE with no encryption"
if low.startswith("https://"):
host = t.split("//", 1)[1].split("/", 1)[0]
return "OPENS A SITE -- you will actually arrive at " + registrable(host)
return "unrecognised -- treat as text, do not act on it"
SAMPLES = [
"https://secure-parking-payments.com.pay-portal.io/session?ref=NCP4417",
"WIFI:T:WPA;S:Free_Airport_WiFi;P:welcome;;",
"bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzz?amount=0.05",
"https://www.nationalrail.co.uk/",
"tel:+449091234567",
]
for s in (sys.argv[1:] or SAMPLES):
print("%s\n -> %s\n" % (s, classify(s)))
You should see: each payload named by what it would cause:
https://secure-parking-payments.com.pay-portal.io/session?ref=NCP4417
-> OPENS A SITE -- you will actually arrive at pay-portal.io
WIFI:T:WPA;S:Free_Airport_WiFi;P:welcome;;
-> JOINS A NETWORK chosen by whoever printed this
bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzz?amount=0.05
-> SENDS MONEY to an address you cannot verify
https://www.nationalrail.co.uk/
-> OPENS A SITE -- you will actually arrive at nationalrail.co.uk
tel:+449091234567
-> DIALS a number -- check it is not premium rate
The fourth sample is there because of a bug this script had when it was first
written. A naive “take the last two labels” rule reports
www.nationalrail.co.uk as co.uk, which is nonsense — nobody owns
co.uk. Several countries divide their namespace two levels deep, so the correct
answer needs a list of those suffixes; the real one, the Public Suffix List, has thousands of
entries and browsers ship a copy. The lesson is worth more than the fix: a domain check
that has never been tried on a non-.com address is probably wrong.
Pass your own text as an argument to check it —
python3 classify.py "https://example.com/x" — using the string your phone
displayed, before you tap it.
If not: if the fourth sample still reports co.uk, the
TWO_LABEL_SUFFIXES set is missing or the length test in registrable was
changed — it needs at least three labels before a two-label suffix can apply. On Windows,
wrap the argument in double quotes or the shell will split it at the &.
Without scrolling up: you scan a code on a parking meter, your phone previews
https://payments.ringgo.co.uk.p-secure.net/pay, and a colleague says it is fine
because it starts with https and names the real operator. What is wrong with both
halves of that, and where does the money go? Answer: https means the connection is encrypted, not that the destination is
honest — any site can obtain a certificate, including this one, so the padlock says nothing
about who you are talking to. And naming the real operator costs nothing: everything before the
final two labels is a subdomain the attacker created freely, which is exactly what step 2
measured. Read it right-to-left and the registrable domain is p-secure.net; the
words ringgo.co.uk are decoration in front of it. The money goes to whoever
registered p-secure.net. The practical response is to leave the code alone and pay
through the operator's own app or the number printed on the sign, both of which you can reach
without trusting a sticker.
Now do it without the page: add two cases to classify.py: a payload beginning
market:// or intent://, which asks a phone to open an app store or a
specific app, and one containing a URL shortener such as bit.ly, whose destination
is unknowable until you follow it. Decide what each should say, then test them. Afterwards, do
the non-code part: next time you are about to scan a code in public, look at whether it is
printed into the sign or stuck onto it. That one glance defeats the most common version of this
fraud entirely.
Summary
- A QR code is a link you cannot inspect -- that is the entire vulnerability
- Read the preview before opening, and check the domain immediately before the first single slash
- Feel for a sticker on any code in a public place
- Never scan a code from an email or a letter -- navigate to the service yourself instead
- HTTPS and perfect branding are not evidence of legitimacy
- If you entered a password, revoke your sessions as well as changing it
Before you scan: who put this code here, and could anyone else have? At a parking meter or a restaurant table the honest answer is often "anyone with a printer" -- and that is when you reach for the official app instead.