Pairing a browser by QR code
Your page shows a QR code, the person scans it with the Touchque app, and the phone that scanned is the one asked to approve, once, with number matching. No username to type, and a phishing page cannot borrow the approval: the QR is bound to this browser's key and the request goes only to the phone that read it.
How it works
- The page sends its browser key's thumbprint (@touchque/web browserKey.thumbprint()) to your server.
- Your server calls qrPairings.create: the SDK makes a nonce, sends only its SHA-256 and returns uri (touchque://pair-browser?…). Show it as a QR code; the nonce never leaves your server.
- Poll qrPairings.get until confirmed, then send login.request with qrPairing: Touchque asks that phone only, for the user it scanned for, once.
States
| state | Meaning |
|---|---|
waiting | Shown, not scanned yet. |
scanned | A phone holding one of your users' accounts read it; the person has not confirmed yet. |
confirmed | The person confirmed on the phone: externalUsername says who, proximity how near the phone was (same network, same country). Send the login request now. |
requested | The login request went to that phone (requestId); wait for it as usual. |
approved | Approved on the phone. |
refused | Your server said no (refuse); the phone shows your reason and message. |
reported | The person said someone told them to scan it: their 2FA freezes. |
expired | Not used within ttlSeconds (30 to 300, default 120). |
Your own checks first. When your rules say no for this browser (an unapproved computer, too many browsers, rate limits), call
qrPairings.refuse with a reason (policy, device_not_approved, too_many_browsers, rate_limited, no_account, other) and a short message: the phone tells the person why. It works after the scan, before the login request.// The page (@touchque/web): the browser key's thumbprint
import { browserKey } from '@touchque/web';
await browserKey.proof(); // makes the key if there is none
const keyThumbprint = await browserKey.thumbprint();
// Your server (Node)
const p = await tq.qrPairings.create({ keyThumbprint, clientIp: req.ip, userAgent: req.get('user-agent') });
// show p.uri (touchque://pair-browser?…) as a QR code, then poll:
const now = await tq.qrPairings.get(p.pairingId); // waiting → scanned → confirmed
if (now.state === 'confirmed') {
await tq.login.request({ externalUsername: now.externalUsername, type: 'LOGIN', qrPairing: p.pairingId });
}
// or, when your own checks say no:
await tq.qrPairings.refuse(p.pairingId, { reason: 'device_not_approved', message: 'Ask IT to approve this computer.' });p = tq.qr_pairings.create(key_thumbprint, client_ip=ip, user_agent=ua)
# show p['uri'] (touchque://pair-browser?...) as a QR code, then poll:
now = tq.qr_pairings.get(p['pairingId']) # waiting -> scanned -> confirmed
if now['state'] == 'confirmed':
tq.login.request(now['externalUsername'], qr_pairing=p['pairingId'])
# or: tq.qr_pairings.refuse(p['pairingId'], 'device_not_approved', 'Ask IT to approve this computer.')$p = $tq->qrPairings->create($keyThumbprint, ['clientIp' => $request->ip(), 'userAgent' => $request->userAgent()]);
// show $p['uri'] (touchque://pair-browser?...) as a QR code, then poll:
$now = $tq->qrPairings->get($p['pairingId']); // waiting -> scanned -> confirmed
if ($now['state'] === 'confirmed') {
$tq->login->request($now['externalUsername'], qrPairing: $p['pairingId']);
}
// or: $tq->qrPairings->refuse($p['pairingId'], 'device_not_approved', 'Ask IT to approve this computer.');p, err := client.QRPairings.Create(ctx, touchque.CreateQRPairingOptions{KeyThumbprint: thumb, ClientIP: ip, UserAgent: r.UserAgent()})
// show p.URI (touchque://pair-browser?...) as a QR code, then poll:
now, err := client.QRPairings.Get(ctx, p.PairingID) // waiting -> scanned -> confirmed
if now.State == "confirmed" {
_, err = client.Login.RequestWithOptions(touchque.LoginRequestOptions{ExternalUsername: now.ExternalUsername, Type: "LOGIN", QRPairing: p.PairingID})
}
// or: client.QRPairings.Refuse(ctx, p.PairingID, touchque.RefuseQRPairingOptions{Reason: "device_not_approved", Message: "Ask IT to approve this computer."})var p = await tq.QrPairings.CreateAsync(new CreateQrPairingOptions { KeyThumbprint = thumb, ClientIp = ip, UserAgent = userAgent });
// show p.Uri (touchque://pair-browser?...) as a QR code, then poll:
var now = await tq.QrPairings.GetAsync(p.PairingId); // waiting -> scanned -> confirmed
if (now.State == "confirmed")
await tq.Login.RequestAsync(new LoginRequestOptions { ExternalUsername = now.ExternalUsername!, QrPairing = p.PairingId });
// or: await tq.QrPairings.RefuseAsync(p.PairingId, new RefuseQrPairingOptions { Reason = "device_not_approved", Message = "Ask IT to approve this computer." });QrPairings.Created p = tq.qrPairings().create(new QrPairings.CreateOptions(thumb).clientIp(ip).userAgent(userAgent));
// show p.uri() (touchque://pair-browser?...) as a QR code, then poll:
JsonNode now = tq.qrPairings().get(p.pairingId()); // waiting -> scanned -> confirmed
if ("confirmed".equals(now.path("state").asText())) {
tq.login().request(new LoginRequestOptions(now.path("externalUsername").asText()).qrPairing(p.pairingId()));
}
// or: tq.qrPairings().refuse(p.pairingId(), "device_not_approved", "Ask IT to approve this computer.", null, null);p = tq.qr_pairings.create(key_thumbprint, client_ip: request.remote_ip, user_agent: request.user_agent)
# show p["uri"] (touchque://pair-browser?...) as a QR code, then poll:
now = tq.qr_pairings.get(p["pairingId"]) # waiting -> scanned -> confirmed
tq.login.request(now["externalUsername"], qr_pairing: p["pairingId"]) if now["state"] == "confirmed"
# or: tq.qr_pairings.refuse(p["pairingId"], reason: "device_not_approved", message: "Ask IT to approve this computer.")# Signed requests (HMAC), see Authentication
POST /qr-pairings
{ "nonceHash": "<base64url SHA-256 of a 32-byte nonce>", "keyThumbprint": "<RFC 7638>", "clientIp": "203.0.113.7", "userAgent": "…", "ttlSeconds": 120 }
→ { "pairingId": "…", "expiresAt": "…" }
# The QR text: touchque://pair-browser?v=1&p=<pairingId>&n=<nonce>&k=<keyThumbprint>
GET /qr-pairings/<pairingId>
→ { "state": "confirmed", "externalUsername": "ayse@acme.com", "proximity": { "sameNetwork": true, "sameCountry": true, "warnings": [] } }
POST /login/request { "externalUsername": "ayse@acme.com", "type": "LOGIN", "qrPairing": "<pairingId>" }
POST /qr-pairings/<pairingId>/outcome { "reason": "device_not_approved", "message": "Ask IT to approve this computer.", "deviceCode": "ABC-123" }