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
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
&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.
ChangeFileSettingsis refused when the file's own Change access right "has access conditions set toFh".[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
SetConfigurationcommand. 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
ChangeKeyagainst them returnsPERMISSION_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
02honly.[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.
- 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
SDMMetaReadandSDMFileReadaccess-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 forChangeFileSettingsandChangeKeybehaviour. - 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
MACtnotation; 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. - 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.
- 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.
- 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.pyrather than taking the word of this page.