Adding QR Code Login to Your Own App — featured illustration
Technology

Adding QR Code Login to Your Own App

6 min read
Create a QR code

The TV in the meeting room is showing a sign-in form and an on-screen grid of letters. Someone is steering a cursor with the remote's arrow keys, one character at a time, and has just discovered that the password contains a capital and two symbols. Four minutes later the form times out and clears itself.

Scan-to-sign-in exists because of that room. The screen that cannot type hands the typing job to the device already holding the credentials.

Building it is not hard. Getting the security model right takes more care than the happy path suggests, because this pattern has one failure mode that no amount of cryptography fixes.

The Shape of the Handshake

Five actors, in order.

  1. The waiting client. Desktop app, smart TV, kiosk, anything unauthenticated with a screen. It asks your API to open a login session.
  2. Your API. It creates a pending record and returns two different values.
  3. The code. The client renders the public half as a QR symbol. Nothing about the pattern is really QR-specific; the symbol is a transport for a short identifier. If the mechanics of that transport are new to you, start with what a QR code actually stores.
  4. The phone. Already signed in. It scans, reads the identifier, and asks your API what it is being asked to approve.
  5. The approval. The user reads a screen that names the requester, taps once, and the waiting client receives tokens.

Two Values, Not One

The most consequential design decision sits in step 2. When a client opens a session, return a pair.

  • A session id, high-entropy and opaque, which goes into the QR code.
  • A client secret, generated at the same moment, which never leaves the waiting client and never appears on screen.

Everyone in the room can photograph the code on the wall. That gets them the session id. It must not get them the tokens. Tokens are released only to a caller presenting the client secret, so the identifier on screen is a pointer to a pending request and nothing more.

Store the record with status (pending, approved, consumed, expired, denied), created_at, expires_at, a hash of the client secret, and the context you will show a human later: application name, platform, app version, coarse location derived from the request IP, and the time the request started.

Waiting Without Hammering

The waiting client now needs to learn when approval lands. Two options.

Polling. GET the status endpoint every two seconds with the client secret in an Authorization header. Simple, survives every proxy, costs one request per client per interval. Cap the total number of polls per session server-side rather than trusting the client to stop.

A held connection. A websocket or a server-sent events stream, opened with the client secret, pushing a single message when state changes. Fewer requests, more moving parts, and you still want a polling fallback for networks that kill long connections.

Either way, responses before approval should carry a status and a countdown, nothing else. No account information leaks to an unapproved session.

What the Phone Does

Your mobile app scans, extracts the identifier, and calls a detail endpoint with the signed-in user's access token. The response is the context you stored: what is asking, from where, since when. The scan itself does nothing beyond reading a string, which is worth remembering if you are used to thinking of a scan as an action.

Then the app shows an approval screen. Not a toast. Not a dialog whose only affordance is a button labeled Continue. A screen that says, in the user's language, which application is requesting access, on what kind of device, from roughly where, and when the request started, with Approve and Deny given equal weight.

On approve, POST to the approve endpoint with the identifier and the user's token. The server checks four things before flipping anything:

  • the session exists and is still pending
  • expires_at has not passed
  • the approving token is valid and belongs to a fully established session, not one created moments ago through this same flow
  • the state transition wins a compare-and-set, so two taps or two devices cannot both succeed

Then it binds the approval to that specific pending record: which user approved, at what time, from which device. The next status call from the holder of the client secret returns tokens, and the record moves to consumed. One session, one set of tokens, no replay.

Expiry Is a Feature, Not a Setting

Sixty to a hundred and twenty seconds. Long enough to find your phone, short enough that a photograph of the screen is stale before it can be forwarded anywhere.

Regenerate on the client when the timer runs out, and make the regeneration visible so the user knows the code in front of them is the live one. Expired records get deleted, not left pending. This is the opposite of a marketing code: a dynamic code is built to stay valid and be repointed for years, while a login code should be dead in two minutes and never come back.

The Attack That Defines This Pattern

Here is the one that matters, and it is not a bug in your code.

An attacker opens a login session on their own machine. Your server does exactly what it should and returns a genuine identifier. The attacker screenshots the code and sends it to a victim with a story: verify your account, confirm the delivery, approve the security check from IT. The victim scans a real code, issued by a real server, and approves it. Tokens are delivered to the attacker's waiting client, for the victim's account.

Nothing was forged. The protocol ran correctly end to end. The only compromised component was the victim's understanding of who was on the other side. It belongs to the same family as codes placed where they do not belong, and it resists technical fixes precisely because the technical part worked.

Three design rules push back.

Never let a login session be approved from a link. If tapping a URL in a message can reach your approve action, the camera ceremony is decoration. Restrict identifier intake to your in-app scanner. When a login URL is opened in a browser or arrives as a deep link, serve an explanation page with no approve control anywhere on it.

Make the approval screen carry information. A user who reads "Sign-in on a smart TV, Berlin, started 8 seconds ago" while sitting in a kitchen in Lisbon has been handed the fact they need. A user who reads "Approve sign-in?" has been handed nothing.

Notify out of band. Push and email after every approval, with the same context and a one-tap way to end that session. The second look catches what the first one missed, which is the general lesson of most account-level QR incidents.

Rate Limits, Logs, and the Rollout

Limit session creation per IP and per device fingerprint, since an open creation endpoint is free enumeration and free load. Limit status polling per session. Limit approvals per account per hour. Log every state transition with requester context and approver identity, and put those logs somewhere support can read them, because "I did not approve that" is a ticket you will receive.

One rendering note for TV and kiosk clients: the code has to survive a camera two meters away in a room with glare. High contrast, a full quiet zone, and a size chosen for where the sofa actually is. Test against the scanner behavior your users have, not only your own phone.

Ship it behind a flag with password login still available, and watch two numbers for a week. Sessions created versus sessions approved tells you if the code is scannable. Time from creation to approval tells you if the approval screen confuses people, which is exactly the state an attacker needs them in.

Share this article