Skip to main content

NFC on iOS Reference

A plain-English dictionary for Core NFC on iOS — what every confusingly-named class, session type, and protocol quirk actually means, why it matters, and a concise Swift code example for each.

iOS NFC has two distinct session types, an acronym-heavy message format inherited from a thirty-year-old spec, and a handful of silent failure modes that will cost you a debugging afternoon if you don’t know about them in advance. This glossary maps every piece of the API to what it actually does in your app.


Tag vs Reader

In NFC terminology, a tag is the passive object being read — a sticker on a museum exhibit, a loyalty card, a transit ticket, a key fob. A reader is the active device that powers and interrogates the tag by bringing it close. On iOS, your iPhone is always the reader. You never implement tag behavior on the phone itself.

The distinction matters because the entire Core NFC API is written from the reader’s perspective. Every class and protocol is named for what the reader does, not for what the tag is. When you see NFCTagReaderSession, it means “a session in which your phone reads a tag,” not “a session that is a tag.”

// There is no "tag role" API on iOS — the phone is always the reader.
// Your app creates a session, and the session detects nearby tags.
import CoreNFC

class ScanController: NSObject, NFCNDEFReaderSessionDelegate {
    var session: NFCNDEFReaderSession?

    func startScan() {
        session = NFCNDEFReaderSession(delegate: self,
                                       queue: nil,
                                       invalidateAfterFirstRead: true)
        session?.alertMessage = "Hold your iPhone near the NFC tag."
        session?.begin()
    }
}

NDEF

NFC Data Exchange Format. A lightweight binary message format standardized by the NFC Forum in 2004, designed to be small enough to fit on a passive sticker with 256 bytes of storage. The name is an acronym from the spec and tells you nothing useful about what it actually carries.

You care because NDEF is the universal language of consumer NFC tags. The tap-to-open-a-URL, tap-to-share-a-contact, and tap-to-join-a-Wi-Fi-network features that users know from Apple Pay tags and product packaging are all NDEF messages. If you want your app to read or write the kind of tag a user can buy at any office supply store, you are working with NDEF. The alternative — raw low-level tag access — is for specialized use cases like transit cards or secure elements.

// An NDEF message is just a container of one or more payload records.
// The simplest case: a tag containing one URL record.
func readerSession(_ session: NFCNDEFReaderSession,
                   didDetectNDEFs messages: [NFCNDEFMessage]) {
    for message in messages {
        for record in message.records {
            // Each record is an NFCNDEFPayload — covered below
            print("Type: \(String(data: record.type, encoding: .utf8) ?? "")")
            print("Payload bytes: \(record.payload.count)")
        }
    }
}

NFCNDEFReaderSession vs NFCTagReaderSession

These are the two session classes, and choosing the wrong one is the most common mistake in Core NFC. Despite similar names, they are for different jobs.

NFCNDEFReaderSession is the high-level option. It understands NDEF and hands you parsed NFCNDEFMessage objects in its delegate callback. It works with any tag that contains NDEF data. Use this when you just want to read or write standard NFC tags.

NFCTagReaderSession is the low-level option. It gives you raw access to the tag hardware and lets you send protocol-specific commands — ISO 7816 APDUs, ISO 15693 inventory scans, FeliCa service reads, MIFARE reads. Use this when you need to talk to transit cards, access control badges, or any tag that is not formatted with NDEF. Note: NFCTagReaderSession can also perform NDEF operations if you ask the discovered tag for its NFCNDEFTag conformance.

// NFCNDEFReaderSession — high-level, NDEF only
let ndefSession = NFCNDEFReaderSession(delegate: self,
                                       queue: nil,
                                       invalidateAfterFirstRead: false)
ndefSession.begin()

// NFCTagReaderSession — low-level, multiple tag technologies
// The polling option tells iOS which tag families to scan for.
let tagSession = NFCTagReaderSession(pollingOption: [.iso14443, .iso15693],
                                     delegate: self)
tagSession?.begin()

NFCNDEFMessage

The top-level container for an NDEF message. It holds an array of NFCNDEFPayload records. A message can contain multiple records — for example, a Smart Poster record is typically an NDEF message with a URL payload record and a text title record inside the same message. The name “message” maps directly to the NDEF spec’s message structure.

You care because when writing to a tag, you construct an NFCNDEFMessage yourself and pass it to writeNDEF. The message’s total byte size must not exceed the tag’s capacity — most cheap tags are 144 bytes or 504 bytes. There is no automatic overflow handling; writing a message that is too large fails with an error.

// Build a message with one URL record and write it to a detected tag
let urlPayload = NFCNDEFPayload.wellKnownTypeURIPayload(
    url: URL(string: "https://example.com")!
)!

let message = NFCNDEFMessage(records: [urlPayload])

// In readerSession(_:didDetect:) — tag is an NFCNDEFTag
tag.writeNDEF(message) { error in
    if let error {
        print("Write failed: \(error.localizedDescription)")
    } else {
        session.alertMessage = "Tag written successfully."
        session.invalidate()
    }
}

NFCNDEFPayload

A single record inside an NDEF message. It contains four fields: a type name format (typeNameFormat), a type identifier (type as Data), an optional ID (identifier as Data), and the actual content (payload as Data). The name is slightly misleading — NFCNDEFPayload represents the whole record structure, not just the raw payload bytes.

You care because the raw payload field follows format-specific encoding rules that are non-obvious. A URL payload prepends a single byte indicating the URI scheme (0x01 for https://, 0x02 for http://, and so on). A text payload prepends a status byte for language code length, followed by a UTF-8 language code. The static factory methods (wellKnownTypeURIPayload, wellKnownTypeTextPayload) handle this encoding for you. When parsing records yourself, you must strip those prefix bytes.

// Reading a text record manually
func parseTextRecord(_ record: NFCNDEFPayload) -> String? {
    guard record.typeNameFormat == .nfcWellKnown,
          record.type == Data([0x54])  // "T" = text record type
    else { return nil }

    let payload = record.payload
    guard !payload.isEmpty else { return nil }

    let statusByte = payload[0]
    let languageCodeLength = Int(statusByte & 0x3F)
    let textStart = 1 + languageCodeLength

    guard textStart < payload.count else { return nil }
    return String(data: payload[textStart...], encoding: .utf8)
}

typeNameFormat (TNF)

The typeNameFormat property on NFCNDEFPayload is an enum (NFCTypeNameFormat) that tells you what kind of type identifier the record carries. Its values are not intuitive because they come directly from the NDEF spec byte field. The most common values are:

.nfcWellKnown — short standardized types like T (text) and U (URI). Most consumer tags use this.

.absoluteURI — the type field itself is a full URI describing the record’s meaning (e.g., urn:nfc:wkt:Sp for Smart Poster).

.media — a MIME type in the type field, like text/plain or application/json. Use this for embedding arbitrary data.

.nfcExternal — a custom type in domain:type format, like com.mycompany:myrecordtype. Use this for app-specific records.

.empty — an empty record with no type or payload. Sometimes used as a terminator or placeholder.

func describeRecord(_ record: NFCNDEFPayload) -> String {
    let typeString = String(data: record.type, encoding: .utf8) ?? "(binary)"

    switch record.typeNameFormat {
    case .nfcWellKnown:
        return "Well Known type: \(typeString)"  // "T", "U", "Sp"
    case .media:
        return "MIME type: \(typeString)"         // "application/json"
    case .absoluteURI:
        return "Absolute URI: \(typeString)"
    case .nfcExternal:
        return "External type: \(typeString)"     // "com.example:ticket"
    case .empty:
        return "Empty record"
    case .unchanged:
        return "Chunked record continuation"
    @unknown default:
        return "Unknown TNF"
    }
}

Well Known Type

A small vocabulary of short type strings defined by the NFC Forum for the most common record types. The type field is an ASCII string, typically one or two characters. The names “Well Known” come from the spec — they are well-known in the same sense that IANA well-known ports are well-known.

The ones you will encounter: T for a plain text record (UTF-8 or UTF-16 with language tag), U for a URI/URL record (with a compressed scheme byte prefix), and Sp for Smart Poster (a composite record wrapping a URL, a title, and optional icon). When you use NFCNDEFPayload.wellKnownTypeURIPayload(url:), the factory sets the type to U and handles the scheme compression automatically.

// Identifying a well-known record type
func identifyWellKnownRecord(_ record: NFCNDEFPayload) {
    guard record.typeNameFormat == .nfcWellKnown else { return }
    let type = String(data: record.type, encoding: .utf8)

    switch type {
    case "T":
        print("Text record")
    case "U":
        // Payload byte 0 is the URI identifier code (scheme abbreviation)
        let schemeCode = record.payload.first ?? 0x00
        let uri = String(data: record.payload.dropFirst(), encoding: .utf8) ?? ""
        print("URI record — scheme code \(schemeCode), suffix: \(uri)")
    case "Sp":
        print("Smart Poster record (contains sub-records)")
    default:
        print("Other Well Known type: \(type ?? "?")")
    }
}

Polling

In NFC, polling is the process by which the reader (your iPhone) repeatedly broadcasts a radio field and listens for a tag response. It is the equivalent of “scanning” in BLE — the phone actively searches rather than waiting passively. The NFC Forum spec refers to this as the “poll loop.”

You care because NFCTagReaderSession requires you to declare which tag technologies to poll for using the pollingOption parameter. Listing only .iso14443 means ISO 15693 tags are ignored entirely during that session — there is no “scan for everything” mode in Core NFC. Choose too narrow a set and users will tap a tag that your session cannot see.

// Poll for ISO 14443 (MIFARE, ISO 7816) and ISO 15693 (inventory tags)
// and FeliCa simultaneously
let session = NFCTagReaderSession(
    pollingOption: [.iso14443, .iso15693, .iso18092],  // iso18092 = FeliCa
    delegate: self,
    queue: DispatchQueue.global(qos: .userInitiated)
)
session?.alertMessage = "Hold your iPhone near the tag."
session?.begin()

Invalidation

An NFC session has a hard lifetime. It ends in one of two ways: your code calls session.invalidate() explicitly, or the system invalidates it automatically — because the scan timed out (roughly 60 seconds), because an error occurred, or because invalidateAfterFirstRead was set to true on NFCNDEFReaderSession. Either way, the delegate method readerSession(_:didInvalidateWithError:) is called.

You care because trying to use an invalidated session — calling begin() again, writing to a tag, or even just reading session.alertMessage — does nothing or crashes. You must create a completely new session object for each scan attempt. Also, the NFCReaderError.readerSessionInvalidationErrorFirstNDEFTagRead error code is not actually an error — it is the normal completion path when invalidateAfterFirstRead is true. Treat it as success.

func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
    let nfcError = error as? NFCReaderError

    switch nfcError?.code {
    case .readerSessionInvalidationErrorFirstNDEFTagRead:
        // Not an error — this is normal completion after reading one tag
        break
    case .readerSessionInvalidationErrorUserCanceled:
        // User dismissed the system sheet
        break
    case .readerSessionInvalidationErrorSessionTimeout:
        DispatchQueue.main.async {
            self.showMessage("No tag found. Please try again.")
        }
    default:
        DispatchQueue.main.async {
            self.showMessage("Scan failed: \(error.localizedDescription)")
        }
    }

    // Do NOT reuse 'session' after this point — it is dead.
    // Create a new NFCNDEFReaderSession next time the user taps scan.
}

Alert Message

The alertMessage property on any NFC session controls the body text of the system-provided HUD sheet that appears while a scan is in progress. Apple requires this sheet — there is no way to suppress it. If you do not set alertMessage, the default text reads “Hold your iPhone near the item to learn more.”

You care because “Hold your iPhone near the item to learn more” is confusing in many app contexts. Set the message before calling begin(). You can also update it mid-session — updating session.alertMessage after a tag is detected (but before calling invalidate()) lets you show confirmation text like “Tag scanned successfully” in the same sheet before it dismisses.

session = NFCNDEFReaderSession(delegate: self, queue: nil, invalidateAfterFirstRead: true)
session?.alertMessage = "Tap your loyalty card to check your points."
session?.begin()

// Later, after detecting a tag, update before invalidating:
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
    // process the message...
    session.alertMessage = "Card scanned. Fetching your balance..."
    session.invalidate()
}

NFCTagReaderSession

The low-level session class. Instead of handing you parsed NDEF records, it hands you an NFCTag enum value in readerSession(_:didDetectTags:). The tag enum has cases for each supported technology: .iso15693, .iso7816, .miFare, and .feliCa. You must connect to the tag, cast to the specific protocol type, and then send commands in that technology’s language.

The important gotcha: you must call session.connect(to: tag) before doing anything else with the tag. This is asynchronous. If you try to send a command before the connect completion handler fires, you get an error. Also, didDetectTags may fire with multiple tags — only the first one is typically useful; the rest should be ignored or the user asked to remove extra tags.

func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) {
    guard let firstTag = tags.first else { return }

    session.connect(to: firstTag) { error in
        if let error {
            session.invalidate(errorMessage: "Connection failed: \(error.localizedDescription)")
            return
        }

        switch firstTag {
        case .iso7816(let tag):
            // Send ISO 7816-4 APDUs
            self.readISO7816(tag: tag, session: session)
        case .iso15693(let tag):
            // Inventory / item management tags
            self.readISO15693(tag: tag, session: session)
        case .miFare(let tag):
            // MIFARE Classic / Ultralight / Plus / DESFire
            self.readMIFARE(tag: tag, session: session)
        case .feliCa(let tag):
            // FeliCa (transit cards common in Japan)
            self.readFeliCa(tag: tag, session: session)
        @unknown default:
            session.invalidate(errorMessage: "Unsupported tag type.")
        }
    }
}

NFCNDEFTag

The protocol that all four tag technology types conform to for NDEF operations. When you are using NFCTagReaderSession but the tag you detected happens to contain NDEF data, you do not need to switch to NFCNDEFReaderSession — you can query the tag for its NFCNDEFTag conformance and read or write NDEF directly.

You care because NFCNDEFTag is easy to miss — it is not a class, it is a protocol mixed into the concrete tag types. Before reading NDEF via this path, you must call queryNDEFStatus to confirm the tag is NDEF-formatted and learn its capacity and writability. Skipping this check and calling readNDEF on an unformatted tag returns a generic error.

func readNDEFFromTag(_ ndefTag: NFCNDEFTag, session: NFCTagReaderSession) {
    ndefTag.queryNDEFStatus { status, capacity, error in
        guard error == nil else {
            session.invalidate(errorMessage: "Cannot query tag.")
            return
        }

        switch status {
        case .notSupported:
            session.invalidate(errorMessage: "This tag does not support NDEF.")
        case .readOnly, .readWrite:
            ndefTag.readNDEF { message, error in
                guard let message, error == nil else { return }
                // Process the message as you would in NFCNDEFReaderSession
                print("Records: \(message.records.count), capacity: \(capacity) bytes")
            }
        @unknown default:
            break
        }
    }
}

ISO 15693

A tag standard designed for item-level identification at longer read ranges (up to about one meter). You see ISO 15693 tags in library books, pharmaceutical packaging, and warehouse inventory systems. The identifying feature is a 64-bit unique identifier (UID) burned into the tag at manufacture. iOS exposes it via the NFCISO15693Tag protocol.

The API for ISO 15693 is block-based — the tag’s memory is divided into fixed-size blocks (usually 4 bytes each), and you read and write by block address. There is no higher-level abstraction. You also get access to the readSingleBlock, writeSingleBlock, and lockBlock commands directly.

func readISO15693(tag: NFCISO15693Tag, session: NFCTagReaderSession) {
    // Read block 0 — first 4 bytes of tag memory
    tag.readSingleBlock(requestFlags: [.highDataRate, .address],
                        blockNumber: 0) { data, error in
        if let error {
            session.invalidate(errorMessage: error.localizedDescription)
            return
        }
        print("Tag UID: \(tag.identifier.map { String(format: "%02X", $0) }.joined())")
        print("Block 0 data: \(data.map { String(format: "%02X", $0) }.joined())")
        session.invalidate()
    }
}

ISO 7816

The standard that governs smart cards — credit cards, SIM cards, passports, transit cards, government ID cards. Communication is structured as command/response pairs called APDUs (Application Protocol Data Units). You send a 4-byte command header (CLA INS P1 P2) plus optional data, and the card replies with data plus a 2-byte status word (SW1 SW2). SW1=0x90, SW2=0x00 means success.

iOS exposes ISO 7816 access via NFCISO7816Tag. You must also add the AID (Application Identifier) of the card’s application to your Info.plist under com.apple.developer.nfc.readersession.iso7816.select-identifiers — Apple uses this to restrict which smart card applications your app can interact with.

func readISO7816(tag: NFCISO7816Tag, session: NFCTagReaderSession) {
    // SELECT by AID — tells the card which application to activate
    // Example: EMV payment application AID (A0000000031010)
    let selectAID = NFCISO7816APDU(
        instructionClass: 0x00,
        instructionCode: 0xA4,  // SELECT
        p1Parameter: 0x04,
        p2Parameter: 0x00,
        data: Data([0xA0, 0x00, 0x00, 0x00, 0x03, 0x10, 0x10]),
        expectedResponseLength: -1
    )

    tag.sendCommand(apdu: selectAID) { responseData, sw1, sw2, error in
        guard error == nil else {
            session.invalidate(errorMessage: "APDU failed.")
            return
        }
        if sw1 == 0x90 && sw2 == 0x00 {
            print("Application selected. Response: \(responseData.count) bytes")
        } else {
            print("Card returned status: \(String(format: "%02X %02X", sw1, sw2))")
        }
    }
}

MIFARE

A family of NFC tag and card products from NXP Semiconductors. The family spans several incompatible technologies: MIFARE Classic (a proprietary protocol that Core NFC does not support for security block reads), MIFARE Ultralight (a simple low-cost tag), MIFARE Plus, and MIFARE DESFire (a high-security smart card). iOS exposes the family via NFCMiFareTag.

The key gotcha: MIFARE Classic tags are extremely common (most older transit cards and access badges), but iOS only supports reading MIFARE Classic tags via the NDEF layer if they happen to be NDEF-formatted. You cannot issue raw MIFARE Classic sector authentication commands. DESFire and Ultralight tags can be accessed via sendMiFareCommand for raw byte-level communication.

func readMIFARE(tag: NFCMiFareTag, session: NFCTagReaderSession) {
    print("MIFARE family: \(tag.mifareFamily)")  // .ultralight, .plus, .desfire, .unknown

    // For Ultralight: READ command (0x30) at page 0 returns 16 bytes (pages 0-3)
    if tag.mifareFamily == .ultralight {
        let readPage0 = Data([0x30, 0x00])
        tag.sendMiFareCommand(commandPacket: readPage0) { response, error in
            guard error == nil else {
                session.invalidate(errorMessage: "Read failed.")
                return
            }
            // Pages 0-1 contain the tag UID; pages 2-3 are lock/capability bytes
            print("Page data: \(response.map { String(format: "%02X", $0) }.joined())")
            session.invalidate()
        }
    }
}

FeliCa

A high-speed NFC technology developed by Sony, primarily used in Japan for transit cards (Suica, Pasmo, ICOCA), electronic money (Edy, nanaco), and building access systems. It is technically a different radio protocol from ISO 14443 and communicates at a higher data rate. iOS exposes it via NFCFeliCaTag. Poll for it with the .iso18092 option in NFCTagReaderSession.

FeliCa organizes data into services (identified by a 2-byte service code) and blocks within those services. Reading data requires knowing the system code of the card and the service codes of the records you want. These are card-specific and documented by the card issuer — there is no universal FeliCa schema.

func readFeliCa(tag: NFCFeliCaTag, session: NFCTagReaderSession) {
    print("Current IDm: \(tag.currentIDm.map { String(format: "%02X", $0) }.joined())")
    print("Current system code: \(tag.currentSystemCode.map { String(format: "%02X", $0) }.joined())")

    // Example: reading 1 block from service 0x000B (a common Suica service)
    // In a real app, verify the system code matches before proceeding.
    let serviceCode = Data([0x0B, 0x00])  // little-endian service code
    let blockList = Data([0x80, 0x00])    // block descriptor: length 1 byte, block 0

    tag.readWithoutEncryption(serviceCodeList: [serviceCode],
                              blockList: [blockList]) { status, blockData, error in
        guard error == nil, status == .success else {
            session.invalidate(errorMessage: "FeliCa read failed.")
            return
        }
        for block in blockData {
            print("Block: \(block.map { String(format: "%02X", $0) }.joined())")
        }
        session.invalidate()
    }
}

Writing to a Tag

Writing NDEF data is available through both session types. The flow is: detect a tag, connect to it (for NFCTagReaderSession) or use the tag handed to readerSession(_:didDetect:) (for NFCNDEFReaderSession), query NDEF status, then call writeNDEF. The critical gotcha is locking: some tags have a one-time-programmable lock mechanism. Once locked, no further writes are possible. You must check whether the tag status is .readWrite before writing, and you should never lock a tag unless the user explicitly consents.

func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) {
    guard let tag = tags.first else { return }

    session.connect(to: tag) { error in
        guard error == nil else {
            session.invalidate(errorMessage: "Could not connect to tag.")
            return
        }

        tag.queryNDEFStatus { status, capacity, error in
            guard error == nil else {
                session.invalidate(errorMessage: "Could not query tag status.")
                return
            }

            guard status == .readWrite else {
                session.invalidate(errorMessage: "This tag is read-only.")
                return
            }

            let payload = NFCNDEFPayload.wellKnownTypeURIPayload(
                url: URL(string: "https://example.com/product/42")!
            )!
            let message = NFCNDEFMessage(records: [payload])

            guard message.length <= capacity else {
                session.invalidate(errorMessage: "Message too large for this tag (\(capacity) bytes available).")
                return
            }

            tag.writeNDEF(message) { error in
                if let error {
                    session.invalidate(errorMessage: "Write failed: \(error.localizedDescription)")
                } else {
                    session.alertMessage = "Tag written successfully."
                    session.invalidate()
                }
            }
        }
    }
}

Background Tag Reading

A special entitlement that lets your app respond to NFC tag taps when it is not in the foreground — without the user needing to open your app first. When the phone detects a tag that matches a domain or URL pattern registered in your Info.plist, iOS wakes your app in the background and delivers the NDEF message via a Universal Link or a custom URL scheme. No session, no system sheet.

This feature has strict requirements: your app needs the com.apple.developer.nfc.readersession.formats entitlement value set to include TAG, your tag must contain a URL that matches an Associated Domain, and the device must be unlocked. Background tag reading is not available for arbitrary tag content — it only works for URL-type NDEF records that map to your registered domains.

// In AppDelegate or SceneDelegate — handle the Universal Link delivered
// when a background-read tag is tapped
func scene(_ scene: UIScene,
           continue userActivity: NSUserActivity) {
    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
          let incomingURL = userActivity.webpageURL else { return }

    // The URL came from an NFC tag read in the background
    // (same code path as a regular Universal Link)
    handleTagURL(incomingURL)
}

Core NFC Entitlement and Info.plist Keys

Core NFC requires an explicit entitlement and Info.plist keys before any NFC code will work. Omitting these produces a cryptic runtime error at session creation, not a compile error.

The entitlement to add to your .entitlements file is com.apple.developer.nfc.readersession.formats with an array value containing NDEF for NDEF sessions and TAG for tag reader sessions. This entitlement must also be enabled in your App ID on the Apple Developer portal.

The required Info.plist keys are: NFCReaderUsageDescription (a user-facing string explaining why you need NFC, shown in the permission context), and — for ISO 7816 sessions — com.apple.developer.nfc.readersession.iso7816.select-identifiers (an array of AIDs as hex strings).

<!-- Entitlements file -->
<key>com.apple.developer.nfc.readersession.formats</key>
<array>
    <string>NDEF</string>
    <string>TAG</string>
</array>

<!-- Info.plist -->
<key>NFCReaderUsageDescription</key>
<string>Scan NFC tags to look up product information.</string>

<!-- Required only for ISO 7816 sessions — list the AIDs your app talks to -->
<key>com.apple.developer.nfc.readersession.iso7816.select-identifiers</key>
<array>
    <string>A0000000031010</string>
</array>