FE/26
MENU

2026-07-29 · 19 min read

The Lookup That Saves the Handshake

ProtocolsArchitectureComplexitySecurity

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:

text
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:

text
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.

DTLS 1.2 RECORD HEADER · TO SCALE · 1 BYTE = 24 PX version epoch sequence number length type 1 2 2 6 2 encrypted fragment · unreadable until a key is chosen 13 readable bytes · none of them name a key
Thirteen bytes of framing, and not one field says which session this record belongs to. Every design decision in RFC 9146 follows from that omission.

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:

text
(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.

RECEIVE PATH · CLASSIC DTLS DATAGRAM FIVE-TUPLE ASSOCIATION KEYS VERIFY 198.51.100.23:62000 DTLS CONTEXT #417 198.51.100.23:49172 NO MATCHING CONTEXT · same device, same keys the record is authentic · the receiver has no way to discover that verification cannot start
The failure is not cryptographic. It is a missing row in a hash table, three stages before any cryptography would have run.

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.

NEGOTIATION · EACH END NAMES ITS OWN INBOX CLIENT SERVER ClientHello · connection_id = C “write C on what you send me” ServerHello · connection_id = S “write S on what you send me” client → server records carry S server → client records carry C two independent values · different lengths · either may be absent
Nothing here is symmetric by requirement. Two directions, two identifiers, each chosen by the side that will have to look it up.

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.

tls12_cid RECORD HEADER · SAME SCALE · 13 → 21 BYTES CONNECTION ID 25 version epoch sequence length 8 bytes content 7A91035210B468CC → DTLS CONTEXT #417 one hash lookup · constant time · independent of the source address +8 bytes
The same thirteen bytes, plus a field that finally belongs to the receiver. Everything else in the record is unchanged, which is the point — this is an insertion, not a redesign.

In structure form:

text
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:

text
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.

text
DTLSInnerPlaintext {
    content;
    real_type;
    zero_padding;
}
INNER PLAINTEXT · BEFORE PROTECTION CoAP message type = 23 00 00 00 00 · optional padding the real type, now cargo a 7-byte reading can look like a 60-byte one ON THE WIRE · WHAT AN OBSERVER READS 25 epoch seq CID A7 3B 1F 9D … opaque visible: association, epoch, sequence, length hidden: what kind of record this is
The type field did not disappear; it changed audience. An observer keeps the routing metadata and loses the semantics — which is a fair description of what a record layer should be leaking in the first place.

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:

text
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:

text
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:

python
if outer_type == tls12_cid:
    association = associations_by_cid[parse_cid(record)]
else:
    association = associations_by_five_tuple[packet.five_tuple]

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:

text
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.

text
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.

RETURN ROUTABILITY CHECK · RFC 9853 ADDRESS B SERVER CID record, seq 43 path_challenge(cookie=8F21…) path_response(cookie=8F21…) address unvalidated anti-amplification limit in force address B validated · CID-to-address binding updated BYTE BUDGET WHILE UNVALIDATED recv may send ≤ 3× buffered data waits · no gain left for a reflector the cookie must come back through the path being tested · that is the entire proof
Two round trips of caution, bounded by a three-times budget so that even the caution cannot be turned into amplification. The check proves reachability and nothing more — which is exactly the claim being made.

So the complete receive path now has two distinct gates, one cryptographic and one about reachability.

RECEIVE PATH · WITH CID AND RETURN ROUTABILITY PARSE CID LOCATE ASSOC AUTHENTICATE ADDRESS SAME? tag invalid → discarded, no reply yes no NEWER RECORD? DELIVER yes no · keep the old binding DELIVER VALIDATE PATH UPDATE BINDING gate one: do you hold the keys · gate two: are you actually there
Delivery and rebinding are decided separately, and a record can pass one gate while failing the other. Collapsing the two decisions into one is precisely the bug the flow exists to prevent.

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.

DEPTH · INTERFACE OVER IMPLEMENTATION 8 bytes, on every packet 7A91…68CC read key · write key · cipher suite · AEAD parameters epoch · sequence number · replay window peer address · local address · path validation state retransmission timers · handshake transcript certificates · session resumption material the caller names it · the caller understands none of it this ratio is the design
Ousterhout's deep module, drawn in bytes rather than method signatures. The interface is eight opaque bytes chosen by the implementation, which is about as narrow as an interface to something this large can get.

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.

TO SCALE · WHAT WAKING UP COSTS FULL HANDSHAKE · ~1600 BYTES · SIX FLIGHTS certificate chain · two elliptic-curve operations · three round trips of radio, awake, at full power 9 BYTES per record · that sliver on the left is the true width no round trips · no asymmetric crypto · no new state 178 protected records at the CID's overhead ≈ one handshake you did not have to run and the radio stays asleep
The comparison is not really about bytes. It is about which of the two costs the device can afford while running on a battery it will not have replaced for a decade.

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.

CORRELATION · THE SAME BYTES, TWO NETWORKS mobile 203.0.113.18:55001 Wi-Fi 198.51.100.23:49172 CID 7A91…68CC CID 7A91…68CC ON-PATH EYE same device negotiated once, at handshake time · DTLS 1.2 has no rotation mechanism structured CID  [region][cluster][shard][conn]  → the identifier also describes your deployment
A stable identifier is a linkable identifier; there is no version of this feature where that is not true. What varies is only whether the deployment decided that trade knowingly.

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.

WHERE THE STATE IS ANCHORED NETWORK ADDRESS SECURITY CONTEXT AUTHENTICATION owned by the network CONNECTION ID SECURITY CONTEXT AUTHENTICATION PATH VALIDATION owned by the receiver the address became data, not identity nine bytes moved the anchor · everything else in this chain is unchanged
Same session, same keys, same records. The only structural difference is which link in the chain the protocol depends on for its identity — and who is allowed to change it.

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

  1. 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

  2. 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

  3. E. Rescorla, N. Modadugu, Datagram Transport Layer Security Version 1.2, RFC 6347, January 2012. https://www.rfc-editor.org/rfc/rfc6347.html

  4. John Ousterhout, A Philosophy of Software Design, Second Edition. Yaknyam Press, 2021. https://web.stanford.edu/~ouster/cgi-bin/book.php

  5. Fabio Ellena, “Risk, Complexity, and Pressure,” 2026. https://fblln.github.io/articles/risk-complexity-and-pressure/