Secure Unique NFC, byte by byte

How a tag signs its own URL

An NTAG 424 DNA rewrites part of its NDEF message every time it is read, and appends a signature over what it rewrote. This page is the arithmetic: which bytes go in, which key signs them, and which eight bytes of the result survive. Everything here is from the NXP data sheet and application note, and the worked example at the end was recomputed from scratch rather than copied.

What the chip is actually doing

The feature is marketed as Secure Unique NFC. The mechanism underneath is called Secure Dynamic Messaging, and it is a property of one file on the chip.

An NTAG 424 DNA holds 416 bytes in three ISO/IEC 7816-4 files: a 32-byte capability container, a 256-byte NDEF file, and a 128-byte proprietary file.[1] Secure Dynamic Messaging can be enabled on the NDEF file and on nothing else. The data sheet is blunt about it: enabling it is "only allowed for FileNo 02h".[1]

When the file is read without authenticating first, the chip does not return the bytes that are stored in it. It returns those bytes with certain spans replaced: the chip's unique identifier, its read counter, optionally a span of file data it encrypts on the fly, and a signature over the result. The spans are chosen in advance by byte offset. The chip is filling in placeholders it was told about at provisioning time.

Nothing authenticates before this happens. That is the point: a phone with no app and no knowledge of the scheme reads an ordinary NFC Forum Type 4 tag and opens an ordinary URL. The proof rides along inside the URL.

If the reader does authenticate, none of this happens. The data sheet: "if authenticated, no mirroring is done, i.e. the regular secure messaging is always applied on the static file data."[1] Reading an SDM tag with a provisioning tool and seeing placeholder text rather than a real URL is the expected behaviour, not a fault.

The counter, and its four awkward properties

The counter is the only source of freshness in the whole scheme. Its limits are therefore the scheme's limits.

SDMReadCtr is a 24-bit unsigned integer. It is reset to 000000h when Secure Dynamic Messaging is enabled with ChangeFileSettings, and in the unauthenticated state it is "incremented by 1 before calculating the response of the first read command".[1] Four things about it will affect your server.

It is not strictly increasing per read command

"On subsequent read commands targeting the same file, the SDMReadCtr is not increased, and the current value is used. As soon as a different command has been received, the counter is incremented again on a subsequent read command."[1] A reader that issues two ReadBinary commands back to back gets the same counter and the same signature twice. A server that requires a strictly greater counter on every request will reject its own legitimate second read.

It does not move at all when authenticated

"Note that the SDMReadCtr is not incremented when authenticated."[1] Anyone holding a key can read the tag as often as they like without disturbing the counter.

It runs out

24 bits is 16,777,215 reads. When the counter reaches FFFFFFh, or an optional lower SDMReadCtrLimit if one was configured, "a first read command arrives at the PICC, an error is being returned".[1] The tag does not wrap around and it does not degrade gracefully. It stops serving the file in the unauthenticated state, which for a tag people tap with phones means it stops working. The counter can only be reset by re-enabling Secure Dynamic Messaging, which requires authentication.

Its byte order changes depending on where you read it

This one costs people a day. "In cryptographic calculations and represented with binary encoding on the external interface, the SDMReadCtr is represented LSB first. When represented with ASCII encoding on the contactless interface, it is represented MSB first."[1] So a tag on read 61 mirrors the text 00003D into the URL, while the three bytes that enter the key derivation are 3D 00 00. Reverse them in the wrong place and every signature fails.

A read counter limit is a side-channel countermeasure, not just a usage cap. NXP gives two reasons for SDMReadCtrLimit, and the second is to "limit the number of traces that can be collected on the symmetric crypto processing. This way potential side channel attacks can be mitigated".[1] It cannot be set to a value at or below the current counter.

What gets mirrored, and whether anyone can read it

The identifier and counter together are called PICCData. One access right decides whether they travel as plain text or as ciphertext.

The SDMMetaRead access right takes three kinds of value: Eh for plain mirroring, 0h to 4h to name one of the five application keys that will encrypt the data before mirroring, and Fh for no mirroring at all.[1]

Plain, which is most tags you will meet

The chip writes the hexadecimal representation of the identifier and the counter as ASCII capitals. The data sheet's own example: the identifier 04E141124C2880h becomes the bytes 30h 34h 45h 31h 34h 31h 31h 32h 34h 43h 32h 38h 38h 30h.[1] A 7-byte identifier therefore occupies 14 characters, and the counter occupies 6. If both are mirrored, the convention inherited from the NTAG 2x family is a literal x (78h) written into the static file data between them as a separator.[1]

Encrypted, and the trade-off nobody mentions

With an application key named, the chip encrypts a single 16-byte block and mirrors its 32 hexadecimal characters. The plaintext is a tag byte, then the identifier, then the counter, then random padding generated by the chip to fill the block:[2]

# one AES block, encrypted with the SDMMetaRead key
C7 04DE5F1EACC040 3D0000 DA5CF60941
|  |               |      |
|  |               |      +-- 5 bytes of chip-generated random padding
|  |               +--------- read counter, 3 bytes, least significant first
|  +------------------------ unique chip identifier, 7 bytes
+--------------------------- PICCDataTag: 1100 0111
                             bit 7  identifier is mirrored
                             bit 6  counter is mirrored
                             bits 3 to 0  identifier length, 7

Encrypting the identifier keeps it off the wire, which is a real privacy gain. It also costs you the ability to diversify that key per tag, because the server needs the key before it knows which tag it is talking to. NXP states the consequence plainly: in this case the meta-read key "shall not be UID diversified and high attention on secure storage on system level of this key is required".[2] One key, every tag in the fleet, and the whole fleet's identifiers behind it. That is a decision about your backend, not about the tag.

Two session keys, derived per read

The key stored on the chip never signs anything directly. It derives a fresh key for each counter value, which is why the counter cannot be changed without invalidating the signature.

Derivation follows NIST SP 800-108 in counter mode, using AES-128 CMAC as the pseudorandom function.[1][3] The input is assembled from four fields: a 2-byte label that separates the two purposes, a 2-byte counter fixed at 0001h, a 2-byte length fixed at 0080h because only 128-bit keys are produced, and a context made of the chip identifier and the counter followed by zero padding if either is absent.[1]

# the two session vectors. Note the label bytes are swapped between them.
SV1 = C3 3C  00 01  00 80  || UID || SDMReadCtr  [|| zero padding]
SV2 = 3C C3  00 01  00 80  || UID || SDMReadCtr  [|| zero padding]

SesSDMFileReadENCKey = CMAC(SDMFileReadKey, SV1)   # encrypts file data
SesSDMFileReadMACKey = CMAC(SDMFileReadKey, SV2)   # signs the message

With a 7-byte identifier and a 3-byte counter the context is exactly 10 bytes, so the vector fills one AES block with no padding at all. That is not a coincidence, and it is why almost every worked example you will find is 32 hexadecimal characters with nothing on the end.

Note that the key doing the deriving is not the same as the key doing the encrypting of PICCData. SDMMetaReadKey encrypts the identifier block directly, with no session key involved. SDMFileReadKey derives the two session keys above. They can be the same application key or different ones, and choosing differently is one of the few levers you have for limiting what a compromised backend component can do.

The signature, and the truncation that catches everyone

An AES-128 CMAC is 16 bytes. The signature on the tag is 8. Which 8 is the single most common implementation mistake in this whole scheme.

The MAC itself is standard: "calculated using AES according to the CMAC standard described in NIST Special Publication 800-38B", zero IV, standard padding.[1] Then it is cut in half. The data sheet says the MAC "is truncated by using only the 8 even-numbered bytes out of the 16 bytes output as described [in SP 800-38B] when represented in most-to-least-significant order".[1] The application note spells the same rule as S14 || S12 || S10 || S8 || S6 || S4 || S2 || S0.[2]

"Even-numbered" is counting from the far end. If you number the 16 bytes S15 down to S0 reading left to right, the surviving bytes are the second, fourth, sixth and so on. In an ordinary zero-indexed byte array that is indices 1, 3, 5, 7, 9, 11, 13 and 15:

# a real 16-byte CMAC, and the 8 bytes that become the signature
full   81 EC 45 C1 75 E7 2F F6 FA C6 1B C7 AB 3B AE F6
index   0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15
kept      EC    C1    E7    F6    C6    C7    3B    F6

SDMMAC = ECC1E7F6C6C73BF6

Take the first 8 bytes instead and every verification fails, with no diagnostic to tell you why. This is worth a unit test with a known vector before you write anything else.

What the input to the MAC is

Not the raw bytes. The MAC is computed over the message as the chip puts it on the wire: DynamicFileData[SDMMACInputOffset .. SDMMACOffset - 1], with DynamicFileData being "the file data as how it is put on the contactless interface, i.e. replacing any placeholders by the dynamic data".[1] Because the mirrored values are ASCII hexadecimal, the MAC input is ASCII text, including any literal characters that happen to sit inside the span. In practice that means the query-string separators get signed too, which is the second place implementations go wrong.

When SDMMACInputOffset equals SDMMACOffset the span is empty and the MAC is computed over a zero-length input.[2] That is the common case for tags that mirror only an identifier and a counter: the signature covers nothing except the session key, and the session key already covers the identifier and the counter.

A worked example, recomputed

Both examples below come from NXP AN12196. Both were recomputed here with an independent AES-128 and CMAC implementation, checked first against FIPS 197 and RFC 4493 test vectors,[4] so the numbers are reproductions rather than transcriptions.

Case one: identifier and counter only

All five application keys are all-zero at delivery, which is what makes this example reproducible. The URL is the one in the application note:[2]

# 1. the URL the tag served
https://ntag.nxp.com/424?e=EF963FF7828658A599F3041510671E88&c=94EED9EE65337086

# 2. decrypt PICCData: AES-128-CBC, zero IV, SDMMetaReadKey = 00..00
D(00000000000000000000000000000000, EF963FF7828658A599F3041510671E88)
  = C7 04DE5F1EACC040 3D0000 DA5CF60941
    tag  identifier      counter 61, LSB first

# 3. build SV2 from the label, the fixed fields, and that context
SV2 = 3CC3 0001 0080 04DE5F1EACC040 3D0000
    = 3CC30001008004DE5F1EACC0403D0000      16 bytes, no padding needed

# 4. derive the MAC session key
SesSDMFileReadMACKey = CMAC(00..00, SV2)
  = 3FB5F6E3A807A03D5E3570ACE393776F

# 5. MAC input offset equals MAC offset, so the input is empty
CMAC(3FB5F6E3A807A03D5E3570ACE393776F, empty)
  = E194C7EE12D9F7EE8A65C8331B704386   bold bytes survive

SDMMAC = 94EED9EE65337086   matches the URL
Step 5 is where the truncation rule earns its keep. Take the first eight bytes of that CMAC and you get E194C7EE12D9F7EE, which is not what is on the tag.

Case two: encrypted file data, and a signed separator

This is the case that teaches the MAC input rule. The offsets in the application note are 3-byte values written least significant first: SDMMACInputOffset is 440000, which is 68, and SDMMACOffset is 6A0000, which is 106.[2] The span is 38 bytes long.

# the URL
https://www.my424dna.com/?picc_data=FD91EC264309878BE6345CBE53BADF40
    &enc=CEE9A53E3E463EF1F459635736738962&cmac=ECC1E7F6C6C73BF6

# PICCData decrypts to identifier 04958CAA5C5E80, counter 080000 (8)
SV2 = 3CC30001008004958CAA5C5E80080000
SesSDMFileReadMACKey = CMAC(00..00, SV2) = 3ED0920E5E6A0320D823D5987FEAFBB1

# bytes 68 to 105 of the message as served. 32 hex characters, then 6 more.
MAC input (text)  CEE9A53E3E463EF1F459635736738962&cmac=
MAC input (bytes) 4345453941353345334534363345463146343539363335
                  37333637333839363226636D61633D
                                   38 bytes, ASCII, including the separator

CMAC(3ED0920E..., that text) = 81EC45C175E72FF6FAC61BC7AB3BAEF6
SDMMAC                       = ECC1E7F6C6C73BF6   matches the URL
The literal characters &cmac= are inside the signed span and are part of the MAC input. Change the parameter name on the tag and you change the signature.

One example in the application note cannot be reproduced. Section 3.4.1 shows a plain identifier and counter mirror with the signature 54A45B2C3A558765 but does not disclose which key produced it, so we could not recompute it and have not reprinted it as if we had. The two cases above are the ones where the note gives a key.

What you cannot undo

Provisioning a tag is mostly reversible until it is not. These are the points of no return, as the data sheet states them.

  • Freezing the file settings. ChangeFileSettings is refused when the file's own Change access right "has access conditions set to Fh".[1] Set that and the offsets, the key assignments and the counter limit are fixed for the life of the tag. This is what you want for a production tag, and it means every offset decision has to be right first.
  • Switching to the leakage-resilient primitive. "The LRP mode can be permanently enabled using the SetConfiguration command. After this switch, it is not possible to revert back to AES mode."[1] LRP buys side-channel resistance and costs you compatibility with every verifier that only implements plain AES.
  • The originality keys. Attempting ChangeKey against them returns PERMISSION_DENIED, described as "targeting any OriginalityKey which cannot be changed".[1] The chip's proof that it came from the manufacturer is not yours to rotate, and it is not yours to revoke either.
  • The counter, in one direction. Enabling Secure Dynamic Messaging resets it to zero, and nothing else moves it backwards. A counter limit cannot be set at or below the current value.[1]
  • Which file carries it. Secure Dynamic Messaging works on file 02h only.[1] If the message you want to sign does not fit in 256 bytes, this chip is not the answer and no amount of configuration changes that.

What this mechanism does not do

Reading the arithmetic makes the gaps obvious, which is the main reason to read it.

The reader never challenges the tag

There is no nonce from the verifier anywhere in the derivation. The only varying input is the counter, and the chip chooses it. That makes the whole exchange a one-way message rather than a protocol, which is exactly why it works with a phone that has no app, and exactly why it cannot prove anything about when the read happened.

A captured URL stays valid until the server has seen it

Freshness is enforced entirely in your backend, by remembering the highest counter seen for each tag. Until that record is written, a URL lifted off someone's screen is as good as the tag. Offline verification, queued verification and multiple verifiers that do not share state all widen that window, and they widen it silently.

The signature is 64 bits

Half of the CMAC is thrown away. A blind forgery therefore succeeds with probability 2-64 per attempt, which is not a practical attack, but it does mean the verification endpoint is the only thing rate-limiting guesses. The chip is not involved in rejecting them.

None of it says the tag is on the right object, or in the room

Those are the two failures that actually happen in the field. They have their own page.

Sources

Two documents, both public. Read on 2026-10-24. Section numbers are given so you can check the wording rather than trust the paraphrase.

  1. NXP, NTAG 424 DNA, Secure NFC T4T compliant IC, NT4H2421Gx product data sheet, Rev. 3.0, 31 January 2019, document 465430. Section 8.2.3.4 for the SDMMetaRead and SDMFileRead access-right values; section 9 for the permanence of LRP mode; section 9.1.3 for the MAC truncation; sections 9.3.1 to 9.3.9 for the counter, PICCData, the MAC and the session keys; sections 10.7.1 and 10.8 for ChangeFileSettings and ChangeKey behaviour.
  2. NXP, AN12196, NTAG 424 DNA and NTAG 424 DNA TagTamper features and hints, Rev. 2.0, 4 March 2025, document 507220. Section 2 for the MACt notation; section 3.3 for session key generation; sections 3.4.1 to 3.4.4 for the mirroring cases and the two worked examples reproduced above.
  3. NIST, SP 800-38B, Recommendation for Block Cipher Modes of Operation: the CMAC Mode for Authentication, and SP 800-108 Rev. 1, Recommendation for Key Derivation Using Pseudorandom Functions. The two standards the chip's construction is assembled from.
  4. Test vectors used to check our implementation before trusting it on the examples above: FIPS 197 Appendix C.1 for AES-128, and RFC 4493 section 4 for AES-CMAC.
  5. The recomputation itself: verify_sdm.py. Pure Python, no dependencies, exits 0 when every hexadecimal value on this page still checks out. Download it and run python verify_sdm.py rather than taking the word of this page.