A sensor completes a DTLS 1.2 handshake with a server. Certificates are verified, an elliptic-curve exchange runs, keys are derived. On the server, the result is a block of state that looks roughly like this:
Peer address: 198.51.100.23:62000
Local address: 203.0.113.7:5684
Protocol: UDP
Cipher suite: TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8
Read key: ...
Write key: ...
Epoch: 1
Receive sequence: 42
Then the device goes to sleep, because it runs on a battery and sleeping is most of what it does. While it sleeps, the NAT mapping in front of it expires. When it wakes up and sends its next reading, the router hands it a different external port:
Before sleep: 198.51.100.23:62000
After sleep: 198.51.100.23:49172
Nothing cryptographic has changed. The device still holds the keys. The server still holds the keys. The record now in flight is perfectly authentic, and the server will discard it.
That sounds like a contradiction until you look at the order in which a receiver has to do things.
#The identifier nobody chose
Here is the whole problem in one picture: the fixed part of a DTLS 1.2 record header, drawn to scale.
Before it can authenticate or decrypt anything, the receiver has to answer a question the record does not contain: which security association is this? It needs the read key, the epoch, the replay window. Classic DTLS answers the question with the only identifier available, which arrived from the layer below:
(source IP, source port, destination IP, destination port, UDP)
The five-tuple was never designed to be a session identity. It is a routing artefact. DTLS adopted it because it was free, and free identifiers are usually the expensive kind — they come with an owner, and the owner is not you. Here the owner is every NAT, every carrier-grade address translator, and every idle timer between the device and the server. Any of them can invalidate your primary key without notifying anyone, and they do it precisely when the device has been quiet, which for a battery-powered sensor is the normal state.
So the receive path looks like this, and it fails at step two.
The obvious escape is to try every key until one verifies. It is also the escape that must not be taken. With a million associations, every unmatched datagram becomes a million AEAD verifications — which is to say, an attacker with a UDP socket and a random payload generator has found your CPU budget. The trial decryption is not merely slow. It is a denial-of-service primitive dressed as robustness.
So the problem is not that the packet cannot be authenticated. The problem is that authentication cannot begin until the receiver knows which key to use, and the only field it had for that purpose belongs to someone else.
RFC 9146 supplies the field.
#Negotiating a connection ID
The extension is connection_id, number 54, and the detail that trips everyone
up on first reading is the direction: each endpoint declares the CID it wants
to receive, not the one it intends to send.
If the server answers with 7A 91 03 52 10 B4 68 CC, then the client stamps that
value on every protected record it sends, and the value is meaningful only inside
the server's own receive table. It is not an identity, not a name, not a
negotiated shared symbol. It is a key into one specific data structure on one
specific machine.
A zero-length CID is legal and says something useful: I support the extension and will happily label records for you, but do not label the ones you send me. That asymmetry is the common case in practice. A constrained client talks to exactly one server, keeps exactly one association, and can resolve any inbound record without help. The server is holding a million associations and needs every byte of help it can get. The protocol lets the cost land where the need is, instead of insisting both ends pay for symmetry neither asked for.
#The record on the wire
Once records are protected, a sender with a non-empty CID switches to a new outer
content type, tls12_cid (25), and inserts the CID after the sequence number.
Same scale as before, so the two headers can be read as a before and after.
In structure form:
DTLSCiphertext {
outer_type = tls12_cid; // value 25
version;
epoch;
sequence_number;
cid[cid_length]; // new
length;
encrypted_content[length];
}
Now the part I find genuinely elegant: there is no CID length field in the record. The record carries the identifier but not its shape. That looks like an oversight and is the opposite of one. The receiver chose the CID, so the receiver already knows how long it is. Putting a length on the wire would be paying, on every single packet, for information one end already possesses.
A deployment can simply declare that all its CIDs are eight bytes, and the parser reads eight bytes. Variable lengths are allowed too, provided the encoding is self-delineating — an implementation might spend two bits of the first byte:
00xxxxxx -> 4-byte CID
01xxxxxx -> 8-byte CID
10xxxxxx -> 12-byte CID
11xxxxxx -> reserved
That scheme is nowhere in RFC 9146. It cannot be, and should not be, because it is not the protocol's business. Which gives the underlying rule:
The endpoint that performs the lookup owns the format of the identifier.
This is interface design, not packet formatting. The party that bears the cost of a decision gets to make it, and the specification declines to standardise something it would only be standardising for the sake of symmetry. Compare it to the failure mode we all know from application code: a shared identifier format negotiated across a boundary, so that any change to one side's indexing strategy becomes a protocol change, a version bump, and a migration for everybody. RFC 9146 refused to create that coupling. The CID is opaque by construction — it has to be, because only one end is ever allowed to interpret it.
#What moved inside the ciphertext
The visible content type of a CID record is always 25. The real type — alert, handshake, application data — moves inside the protected payload.
DTLSInnerPlaintext {
content;
real_type;
zero_padding;
}
Two things come along for the ride. An observer can no longer tell an alert from application data from a rekeying handshake message, and padding becomes possible, so a seven-byte temperature reading need not be identifiable as a seven-byte temperature reading.
The structural bill for all of this, with an eight-byte CID and no padding:
CID in the record header 8 bytes
Encrypted real content type 1 byte
--------
9 bytes
Nine bytes. Hold that number; I want to come back to what it buys.
#A visible identifier that is not a credential
The CID is not secret. Anyone on the path reads it, and anyone can copy it into a datagram with a forged source address and random ciphertext. That forged packet will find context #417. The lookup succeeds — and then nothing else does. For AEAD suites, RFC 9146 folds the CID and the record metadata into the authenticated additional data:
additional_data =
8 octets of 0xFF // stands in for the classic seq_num field
+ tls12_cid // 25
+ cid_length
+ tls12_cid // 25, again
+ version
+ epoch
+ sequence_number
+ cid
+ length_of_inner_plaintext
The content type appearing twice is not elegance. It is compatibility — the construction preserves the shape the classic computation had, so existing code paths and their length assumptions survive. Specifications that live in deployed firmware make this trade constantly, and it is usually the right one; an ugly constant costs a line of code, whereas a re-shaped computation costs an interoperability matrix.
The important property is the guarantee that falls out. Modify the CID, the epoch, the sequence number, or the payload, and the tag fails. So:
The CID is a lookup hint, protected by the record it labels. It is not a bearer token, and by itself it proves nothing.
I would put that sentence on a wall somewhere near most session-handling code I have reviewed, because the common mistake is the exact inverse: an identifier used simultaneously for finding state and for authorising access to it. A session cookie that is both the row key and the proof of ownership. A tenant ID pulled from a header and trusted because it was specific enough to work. Once one string does both jobs, disclosure of the identifier becomes escalation of privilege, and you cannot log it, cache it, put it in a URL, or hand it to a load balancer without thinking about who is watching.
DTLS keeps the two jobs apart, and the separation is what makes the CID safe to print in clear text on every packet. Addressing is public. Authority stays with the keys.
#Finding the association is not finding the peer
With a CID in place, the sleeping sensor wakes up, sends its record from a new port, and the server resolves it in one lookup:
=
=
The record authenticates. The server now knows, with cryptographic certainty, that this datagram was produced by the holder of the connection keys.
It still does not know where to send the reply.
Consider an attacker who captured a valid record earlier — say sequence 40, carrying "give me current state" — and replays it verbatim with a forged source address. Everything checks out, because everything is authentic; it was authentic when it was recorded. If the server treats a successful decryption as permission to move the peer address, it will now start sending state reports to an address of the attacker's choosing, and it will amplify small forged datagrams into large real ones. The protocol has been turned into a reflector.
RFC 9146 therefore sets three conditions before a peer address may be updated:
1. The record passes cryptographic verification.
2. Its epoch and sequence number make it newer than the newest
record previously received.
3. The implementation has a strategy for proving that the peer can
receive and process records at the new address.
The second condition does quiet, necessary work: it stops a late-arriving old packet from dragging the binding backwards.
sequence 43 from address B -> binding moves to B
sequence 42 from address A -> ignored; A is the past
The third condition is the interesting one, because in RFC 9146 it is not a mechanism at all. It is a requirement to have one. The specification names the obligation and leaves the discharge to the application protocol or the implementation.
I have mixed feelings about holes like this, and I recognise them from architecture reviews. Leaving the mechanism unspecified was defensible: DTLS sits underneath application protocols that may already have a heartbeat, and mandating a redundant one would have been the protocol legislating a concern it does not own. But an obligation with no mechanism is an obligation that gets implemented differently everywhere, or quietly not at all — and the failure mode of "not at all" is an open reflector, which is silent, remote, and not visible in any test the implementer was likely to write. Unspecified is not free. It is design debt with the interest payable by whoever ships.
#The missing mechanism arrived four years later
RFC 9853, in March 2026, closes the hole with a standardised Return Routability Check. When a valid record turns up from a new address, the receiver challenges the path with an unpredictable 64-bit cookie before it will trust it.
So the complete receive path now has two distinct gates, one cryptographic and one about reachability.
Two mechanisms, two proofs, two questions:
Who holds the keys? Answered by the record. Answered by the CID's presence in the authenticated data.
Who is at this address? Not answered by the record at all. Answered only by something arriving back through the path.
The CID solves security-context discovery. Return routability solves path ownership. They look like one problem — "the client moved" — and they are not, and the four years between the two documents is the evidence.
#The eight bytes are an interface
There is a way of reading the CID that I like better than "a NAT workaround".
The security association is a large, stateful, complicated thing. Keys, epochs, a replay window, cipher parameters, retransmission timers, path state, sometimes a handshake transcript. It is exactly the sort of object whose complexity you want to be able to not think about from the outside.
That is a deep module in the sense I have written about before: the interface is dramatically simpler than what it hides, and the ratio is the whole point. The CID names the association without describing it. The sender knows the eight bytes and nothing else — not the format, not the table, not the sharding scheme, not whether the server keeps associations in memory or reconstructs them from an encrypted blob. All of it is free to change on the receiving side without a single packet on the wire changing shape.
And note what happens without the CID. The interface to the association was the five-tuple, which is not opaque, not chosen by the owner, and not stable — an interface made of someone else's mutable implementation detail. Every property you want from a module boundary was violated by the identifier, and the visible symptom was a sensor that could not talk to its server after a nap.
Bad identifiers are bad interfaces. It is the same failure, at a different scale, as a public method that takes a struct the caller had to reverse-engineer from your storage layout.
#What nine bytes buy
Now the accounting, because a design that adds bytes to every packet on a constrained network needs to answer for them.
Without a CID, a NAT rebinding means the association is unreachable, and the only recovery is a new handshake: several flights, a certificate chain, asymmetric operations at both ends, and — the part that actually kills the device — multiple round trips with the radio powered up. Nine bytes per record against that, and the device gets to send one datagram and go back to sleep.
That is the trade in one line: a small, permanent, predictable cost on the common path, in exchange for removing a large, occasional, unpredictable one. It is the same trade as an index on a table, or a version field in a serialised struct, or a request ID threaded through a distributed system. Nobody notices the nine bytes. Everybody notices the handshake storm when ten thousand sensors wake up at sunrise and all of them have new ports.
#The price of persistence
And now the part that keeps the design honest, because the property that lets a connection survive a network change is the same property that lets someone follow it across one.
DTLS 1.2 negotiates CIDs once, at the start of the session, and offers no way to rotate them mid-session. RFC 9146 says so plainly and advises against CIDs in mobility or multihoming deployments where cross-path correlation matters. A sensor bolted to a wall behind one NAT is a different proposition from a device that roams between a phone hotspot and an office network several times a day.
The receiver's control over the CID format cuts both ways here too. A structured CID — region, cluster, shard, connection — is convenient for routing, and it also publishes a sketch of your infrastructure to anyone with a packet capture, and gives an attacker a way to aim. Random and opaque leaks less. Owning the format means owning what it discloses.
This is what an honest mechanism looks like: it solves the problem it claims to solve, it names the new problem it creates, and it declines to pretend the second one is small.
#Where the state is anchored
Strip the document back and RFC 9146 introduces: an extension number, a content type, a field in a header, a modification to the AEAD input, a relocation of one byte into the ciphertext, optional padding, and a set of rules for what to do when a peer's address changes. Nine items, none of them individually clever.
The change that matters is the first operation performed on an incoming packet.
The pressure here was concrete and measurable: NAT timers shorter than device sleep cycles, and a battery that cannot pay for a public-key handshake every time the network forgets about it. The risk was handshake storms, dropped readings, and devices that appear dead for reasons no log explains. The control was a stable identifier owned by the party doing the lookup. The mechanism was nine bytes and a return routability check.
Nothing about that chain is protocol-specific. The recurring engineering mistake it corrects is anchoring durable state to a borrowed, mutable identifier, and it is everywhere: sessions keyed by IP, caches keyed by URL when the URL contains a rotating token, entities keyed by an email address the user is about to change, distributed state keyed by a hostname that the orchestrator will reassign at the next deploy. The system works, right up until the identifier's real owner exercises their right to change it, and then the failure looks like corruption or a mystery rather than a design decision made years earlier by someone who wanted to avoid inventing a key.
The fix, whenever it is available, is the same as RFC 9146's: mint your own identifier, keep it opaque, let the party performing the lookup choose its shape, and never let it double as proof of anything.
For a sensor on a wall, that is the difference between one protected datagram and an entire handshake.
#References
-
E. Rescorla, H. Tschofenig, T. Fossati, A. Kraus, Connection Identifier for DTLS 1.2, RFC 9146, March 2022. https://www.rfc-editor.org/rfc/rfc9146.html
-
H. Tschofenig, T. Fossati, Return Routability Check for DTLS 1.2 and DTLS 1.3, RFC 9853, March 2026. https://www.rfc-editor.org/rfc/rfc9853.html
-
E. Rescorla, N. Modadugu, Datagram Transport Layer Security Version 1.2, RFC 6347, January 2012. https://www.rfc-editor.org/rfc/rfc6347.html
-
John Ousterhout, A Philosophy of Software Design, Second Edition. Yaknyam Press, 2021. https://web.stanford.edu/~ouster/cgi-bin/book.php
-
Fabio Ellena, “Risk, Complexity, and Pressure,” 2026. https://fblln.github.io/articles/risk-complexity-and-pressure/