DEV Community

Cover image for Building quishing protection into a React Native QR scanner (no server needed)
Nexa Tech
Nexa Tech

Posted on

Building quishing protection into a React Native QR scanner (no server needed)

ScanQR is on App Store and Google Play. I'd love feedback, especially regional phishing patterns or payment formats I should add next. πŸ™

Most QR scanner apps do one thing: decode the string and throw it at the browser. That's precisely what quishing (QR phishing) relies on. A sticker over a parking meter, a fake "pay here" code on a restaurant table, a parcel notice with a code. You can't hover over a QR code to preview the link.

I'm building ScanQR, a React Native scanner. For this release I wanted it to understand what it scans before the user taps anything. Here's how two parts of that work: link safety checks and payment QR parsing.

1. Link safety: offline heuristics first

I didn't want every scan to need a network round trip, so the first pass is pure string analysis that runs instantly and offline:

export type RiskReason =
  | 'shortener'       // bit.ly, qrco.de… hides the real destination
  | 'punycode'        // xn-- labels: look-alike characters
  | 'ip_host'         // http://185.23.x.x/login
  | 'userinfo'        // https://paypal.com@evil.site
  | 'insecure'        // plain http
  | 'suspicious_tld'  // .zip, .mov, .top, .xyz…
  | 'impersonation'   // paypal-secure-login.com
  | 'many_subdomains'
  | 'malicious';      // remote verdict
Enter fullscreen mode Exit fullscreen mode

A few of these are more interesting than they look.

The @ trick. In https://paypal.com@evil.site/, everything before @ is userinfo, so the real host is evil.site. I pull the host from the authority section and take what comes after the last @:

const authority = m[1];
const at = authority.lastIndexOf('@');
const hostPort = at >= 0 ? authority.slice(at + 1) : authority;
Enter fullscreen mode Exit fullscreen mode

Brand impersonation. I look for a brand name anywhere in the host (with dots and dashes removed), unless the registrable domain belongs to that brand:

const owner = registrableLabel(host);          // "example" for a.b.example.co.uk
const flat = host.replace(/[-.]/g, '');
for (const [name, official] of Object.entries(BRANDS)) {
  if (flat.includes(name) && !official.includes(owner)) {
    reasons.push('impersonation');
    break;
  }
}
Enter fullscreen mode Exit fullscreen mode

So paypal-secure.verify-account.xyz gets flagged, but www.paypal.com and paypal.co.uk pass. The brand list includes local targets too (Vietcombank, Techcombank, Shopee, Lazada), because phishing is regional.

Severity, not a yes/no answer. Some signals are near-certain red flags. Others are only "be careful":

const DANGER = ['userinfo', 'punycode', 'impersonation', 'ip_host'];
const level = reasons.some(r => DANGER.includes(r))
  ? 'danger'
  : reasons.some(r => r !== 'insecure' && r !== 'many_subdomains') || reasons.length >= 2
  ? 'caution'
  : 'safe';
Enter fullscreen mode Exit fullscreen mode

A plain http:// link alone doesn't deserve a red banner. Crying wolf trains users to ignore warnings.

Then, optionally, ask Google

On top of the local result, the app can query Google Web Risk (uris:search) with a 5-second timeout. It's merged in only if it returns a verdict. If the device is offline or the request times out, the local result still stands:

export const withRemoteVerdict = (local: UrlRisk, malicious: boolean | null) =>
  malicious ? { ...local, level: 'danger', reasons: ['malicious', ...local.reasons] } : local;
Enter fullscreen mode Exit fullscreen mode

2. Payment QRs: one parser, many countries

VietQR, PromptPay, PayNow, DuitNow, QRIS and Pix are all built on the EMVCo Merchant-Presented QR spec. The payload is a flat TLV string: 2-digit tag, 2-digit length, value. The parser fits in a few lines:

export const parseTlv = (s: string) => {
  const out: Record<string, string> = {};
  let i = 0;
  while (i < s.length) {
    if (i + 4 > s.length) return null;
    const tag = s.slice(i, i + 2);
    const len = Number(s.slice(i + 2, i + 4));
    if (!Number.isInteger(len) || i + 4 + len > s.length) return null;
    out[tag] = s.slice(i + 4, i + 4 + len);
    i += 4 + len;
  }
  return out;
};
Enter fullscreen mode Exit fullscreen mode

Tag 54 is the amount, 59 the merchant name and 60 the city. Nested templates like 38 (VietQR) or 62 (additional data) are TLV strings themselves, so you just call parseTlv again.

Every EMVCo payload ends with 6304 plus a CRC-16/CCITT-FALSE checksum. Checking it costs almost nothing and catches damaged or edited codes:

export const crc16 = (s: string) => {
  let crc = 0xffff;
  for (let i = 0; i < s.length; i++) {
    crc ^= s.charCodeAt(i) << 8;
    for (let j = 0; j < 8; j++) {
      crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff;
    }
  }
  return crc.toString(16).toUpperCase().padStart(4, '0');
};

const crcValid = crc16(s.slice(0, -4)) === s.slice(-4).toUpperCase();
Enter fullscreen mode Exit fullscreen mode

If the checksum fails, the user sees "QR may be damaged (checksum mismatch)". They're about to send money, so that warning matters. UPI, SEPA (EPC) and Swiss QR-bill use different formats. They get their own parsers, and IBANs are checked with mod-97.

3. When you generate codes, check them too

People love custom colors and a logo in the middle of their QR code, and then nobody can scan it. After rendering, ScanQR scans its own output and shows "Scannable β€” verified" or a warning. It also warns about low contrast, inverted colors and a logo that's too large.

Stack

  • React Native 0.81 (New Architecture), Reanimated 4
  • react-native-vision-camera + ML Kit barcode scanning
  • Open Food Facts / Open Beauty Facts / Open Library for product barcodes
  • i18next with 23 languages

Lessons

  1. Do the work offline first. The local heuristics give an answer instantly, and the network check only adds to it.
  2. Graded warnings beat yes/no. Too many red banners and users stop reading them.
  3. Checksums are free safety. If a format has one (EMVCo CRC, IBAN mod-97), check it.

ScanQR is on App Store and Google Play. I'd love feedback, especially regional phishing patterns or payment formats I should add next. πŸ™

Top comments (0)