Implementing NFC/RFID Product Tag Reading in Mobile Applications
NFC in phone — HF RFID at 13.56 MHz. Reads same chips as stationary HF readers: MIFARE Classic/Ultralight, NTAG213/215/216, ICODE SLI, ISO 15693. For reading product tags in retail, authenticity verification, or warehouse operations — built-in NFC works without external equipment. Range — 1–5 centimeters. Not UHF, not mass reading. But works on any modern smartphone.
iOS: CoreNFC
import CoreNFC
class ProductTagReader: NSObject, NFCNDEFReaderSessionDelegate {
private var session: NFCNDEFReaderSession?
var onProductFound: ((ProductInfo) -> Void)?
func startReading() {
guard NFCNDEFReaderSession.readingAvailable else {
showError("NFC not available on this device")
return
}
session = NFCNDEFReaderSession(delegate: self, queue: nil, invalidateAfterFirstRead: false)
session?.alertMessage = "Apply phone to product tag"
session?.begin()
}
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
for message in messages {
for record in message.records {
guard record.typeNameFormat == .nfcWellKnown,
let type = String(data: record.type, encoding: .utf8),
type == "U" else { continue }
// URL record in NDEF — standard for product tags
if let urlString = parseNDEFUrl(record.payload),
let url = URL(string: urlString) {
fetchProductInfo(from: url)
}
}
}
}
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
if let nfcError = error as? NFCReaderError,
nfcError.code != .readerSessionInvalidationErrorFirstNDEFTagRead,
nfcError.code != .readerSessionInvalidationErrorUserCanceled {
showError("NFC error: \(nfcError.localizedDescription)")
}
}
}
invalidateAfterFirstRead: false — session doesn't close after first read. Useful for sequential checking multiple products without restarting session.
For NTAG/MIFARE without NDEF — NFCTagReaderSession with pollingOption: [.iso14443]:
func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) {
guard let tag = tags.first else { return }
session.connect(to: tag) { [weak self] error in
if let error = error {
session.invalidate(errorMessage: "Error: \(error.localizedDescription)")
return
}
switch tag {
case .miFare(let mifareTag):
let uid = mifareTag.identifier.hexString
self?.lookupProduct(uid: uid)
case .iso15693(let isoTag):
// ICODE SLI for box tags in logistics
let uid = isoTag.identifier.hexString
self?.lookupProduct(uid: uid)
default:
session.invalidate(errorMessage: "Unsupported tag type")
}
}
}
Android: NFC Foreground Dispatch
class ProductScanActivity : AppCompatActivity() {
private lateinit var nfcAdapter: NfcAdapter
private lateinit var pendingIntent: PendingIntent
private lateinit var filters: Array<IntentFilter>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
nfcAdapter = NfcAdapter.getDefaultAdapter(this)
?: run { showNoNfcMessage(); return }
pendingIntent = PendingIntent.getActivity(
this, 0,
Intent(this, javaClass).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_MUTABLE
)
filters = arrayOf(IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED).apply {
addDataType("*/*")
})
}
override fun onResume() {
super.onResume()
nfcAdapter.enableForegroundDispatch(this, pendingIntent, filters, null)
}
override fun onPause() {
super.onPause()
nfcAdapter.disableForegroundDispatch(this)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
when (intent.action) {
NfcAdapter.ACTION_NDEF_DISCOVERED -> handleNdefTag(intent)
NfcAdapter.ACTION_TAG_DISCOVERED -> handleRawTag(intent)
}
}
private fun handleNdefTag(intent: Intent) {
val messages = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)
?.filterIsInstance<NdefMessage>() ?: return
messages.flatMap { it.records.toList() }
.filter { it.tnf == NdefRecord.TNF_WELL_KNOWN && it.type.contentEquals(NdefRecord.RTD_URI) }
.forEach { record ->
val url = parseNdefUri(record.payload)
viewModel.loadProduct(url)
}
}
}
Foreground dispatch intercepts NFC tags while app active. Without it, Android shows system app selection dialog.
Tag Formats and What to Store
| Chip | Memory | Typical Use |
|---|---|---|
| NTAG213 | 144 bytes | URL to product page |
| NTAG215 | 504 bytes | URL + JSON with basic attributes |
| NTAG216 | 888 bytes | Extended data, history |
| MIFARE Ultralight | 48 bytes | Only UID (no data space) |
For authenticity verification: tag stores signed token, app verifies signature with public key offline:
// ECDSA signature verification from tag
func verifyAuthTag(_ signedPayload: Data) -> Bool {
let publicKey = getEmbeddedPublicKey() // embedded in bundle
return SecKeyVerifySignature(
publicKey,
.ecdsaSignatureMessageX962SHA256,
productId as CFData,
signature as CFData,
nil
)
}
Timeline
NDEF-URL reading and product info loading: 2–3 days. Custom tag formats with signature verification and offline database: 1–2 weeks.







