DEV Community

Amr Abumady
Amr Abumady

Posted on

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 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)

  1. 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.
  2. The attacker copies that QR code onto a page they control: a fake "scan to claim your reward" page, a fake support chat, anything.
  3. 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.
  4. 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}/exchange and 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.

Top comments (1)

Collapse
 
furqan_ashraf profile image
Furqan Ashraf •

Good writeup, the four fixes make sense. QR-jacking still needs the attacker to host the page that shows the stolen code, and those pages usually sit on fresh lookalike domains or free hosting subdomains. Blocking known phishing domains at DNS or the browser layer adds one more barrier on top of the protocol fixes. Daily phishing domain feeds like this one are built for that use case: whoisfreaks.com/products/threat-intelligence-feed