QR-code login can be phished. Here is how we closed the hole
QR-code login looks safe. You scan a code, approve on your phone, and the computer is signed in. No password typed, nothing to phish. We run ClientN, an anonymous sign-in service, and our own QR flow had the same hole m
QR-code login looks safe. You scan a code, approve on your phone, and the computer is signed in. No password typed, nothing to phish.
We run ClientN, an anonymous sign-in service, and our own QR flow had the same hole most QR logins have. This post is about what that hole was and the four changes we shipped to close it.
The attack (QR-jacking)
- The attacker opens a real website that uses ClientN and starts a sign-in. The site shows a QR code and a short match code.
- The attacker copies that QR code onto a page they control: a fake "scan to claim your reward" page, a fake support chat, anything.
- The victim scans it. Their phone opens the real ClientN page, on the real domain, with a valid TLS certificate. They see a match code, and the fake page shows the same one, because the attacker copied it.
- The victim approves. The website signs in the attacker's browser, because that is the browser that started the request.
Nothing was faked on our side. The victim approved a real request, just not one they started. Match codes don't help when the attacker can show the same code. Passkeys don't help either: the passkey proves the victim is the victim, not that the victim is sitting at the computer that asked.
There was a second, smaller problem. If you were already signed in to ClientN, approving a website was one tap on "Confirm". No fingerprint. Anyone holding your unlocked phone could approve sign-ins.
What we changed
1. A fresh passkey for every approval
Approving a website sign-in now always asks for the passkey again, and the WebAuthn challenge is tied to that one sign-in request. Having a ClientN session cookie is no longer enough to approve anything.
2. Same-device sign-in returns a one-time code bound to the browser
When the person signs in on the same device, the site redirects to ClientN and ClientN redirects back. This works much like OAuth's PKCE:
- When the site creates the request, it sends a
code_challenge(a SHA-256 hash of a random secret it keeps). - After approval, ClientN redirects back to the site's registered return address with a one-time code.
- The site swaps the code at
POST /api/v1/sessions/{id}/exchangeand must send the original secret.
The secret only exists in the browser session that started the sign-in. A forwarded link, or a code caught on the way, is useless to anyone else. This is now the default flow; QR is for the "phone for a computer" case only.
3. The QR flow shows where the request came from
For the cross-device case we can't bind to the browser, so we give the person enough to notice an attack:
- Sites send the visitor's IP and user agent when they create the request. The approval page shows "Chrome on Windows ยท Egypt", read from a local country database. No IP is sent to a third party.
- If the requester's country differs from the phone's, the page shows a red warning.
- Instead of just reading a match code, the person must pick the code shown on the website out of three. One wrong pick denies the request.
- A "This wasn't me" button cancels the request on the spot.
None of this stops someone who ignores every warning. It does turn "tap approve" into a step where the person has to check something, and a lazy phishing page fails at the number pick.
4. Account-side signals
- The account page lists recent activity with the country it came from.
- We send an email when a new passkey is added and when an account signs in to a new website for the first time.
- Sign-in request data (IP, user agent, country) is erased after 30 days.
What it cost the websites
Two small changes: send end_user_ip and end_user_agent when creating a session, and handle the return code with the exchange call. We had one connected site, so the timing was easy. If you are building QR login yourself, do this before you have integrations to migrate.
Our open-source starter kit has working PHP and Node versions of the full flow, with 88 tests (35 Node, 53 PHP), including "another browser cannot take over a login even if it knows the session id": github.com/clientn/clientn-session-starter.
The whole model, in plain words, is on our security page. You can try both flows on the demo site.
If you see a way around any of this, I'd like to hear it in the comments.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ full credit and traffic to the original publisher.