TLS & Security Primer
Right after the TCP handshake for curl https://api.example.com/user/42, a second handshake runs. This page proves three things about it: that encryption alone buys almost nothing, that one guessed key share is the whole difference between one round trip and two, and that the expensive part of TLS is not the encryption. Seven sections: the unprotected byte, the two cipher families, agreeing on a key in public, the handshake, certificates, the record layer, and how it fails.
What an unprotected byte looks like
Six machines between curl and the server can read the wire. TLS hides the connection from none of them. It makes what they read useless.
Every hop terminates a link and forwards a packet, and each one holds those bytes in its own memory while it does. That is not an attack, it is how routing works. The only question is what the bytes say when they get there.
Drag the observer along the path and switch the scheme. Over plain http:// every hop reads the whole request; over https:// the same hop reads a header and noise:
Notice the observer never leaves the wire. TLS changes nothing about who can see the packet — the café access point still forwards every byte of it. What changes is that the URL, the cookie and the body are no longer inside the packet at all.
Confidentiality is only the first of three guarantees, and on its own it is close to worthless. Flip one byte of the ciphertext and watch what reaches the application — first with a cipher that only encrypts, then with an AEAD tag:
Watch byte 7. Under a stream cipher with no tag, flipping one bit of the ciphertext flips exactly that bit of the plaintext: amount=10.00 becomes amount=90.00 and the receiver cannot tell. The attacker never learned the key and never needed to. With AEAD the same edit yields bad_record_mac and the record is dropped.
That leaves authenticity, which is the one people drop. Step an interceptor through a connection with the certificate check switched off, then switch it on and step again:
Because the client never asks who signed the certificate, the attacker completes a perfectly encrypted handshake — to itself — and proxies onwards to the real server. Turning the check on stops it at step 3 with unknown_ca (48). Encryption to the wrong endpoint is not a weaker guarantee; it is no guarantee.
TLS buys all three with one layer, sitting under the application and over TCP. Walk down the stack and watch what the same 176-byte request weighs at each level:
The record layer adds 22 bytes: a 5-byte header, one byte of content type hidden inside the ciphertext, and a 16-byte tag. It does not know what it carries. HTTP, IMAP, gRPC and AMQP all get identical treatment, which is why stunnel can wrap a protocol that has never heard of TLS.
One practical consequence, and it decides where your outages come from. TLS terminates wherever the private key lives, which in production is almost never the process handling the request — move the slider through the three things a load balancer speaks to its backend:
The middle box holds the certificate, so the version policy, the cipher list and the expiry date all live there — and so does nearly every TLS incident. Behind it, plain HTTP leaves one segment in cleartext and trusts that nobody mis-routes a packet or lands a shell on a neighbouring host.
Two families of cipher, and where each one is cheap
One is fast and needs both sides to already share a key. The other is slow and does not. The ratio between them decides the shape of the protocol.
A symmetric cipher runs the same key forwards and backwards. AES has been an instruction rather than a library since Westmere, so on a server core it is closer to a memory-bandwidth problem than a compute one. Public-key operations are not.
Drag the payload size and read all three off one log axis — bulk encryption against one X25519 exchange and one P-256 signature:
At the opening 1 KB, the bulk work is 0.20 µs and the signature is 29 µs — 147 times more, for four hundred times fewer bytes. Drag upwards and the two markers only meet between 128 KB and 256 KB. That is the entire design: pay the public-key cost once per connection and never again.
The asymmetric side is slow because each bit of key buys less. Step through the security levels and compare what RSA and a curve each need to reach them:
Notice the gap widens as you climb. At 112 bits RSA needs 2,048 bits against the curve's 224 — nine times; at 256 bits it needs 15,360 against 512 — thirty times. That is why every new server certificate is ECDSA P-256: the same 128-bit strength as RSA-3072 in a twelfth of the key material. (NIST SP 800-57 Part 1 Rev. 5, Table 2.)
Neither family is usable until something detects tampering, and modern TLS folds that into the cipher itself. The record opens on a sixteen-byte payload — drag it up and watch what the tagstops costing:
At a 16-byte payload the record is 38 bytes on the wire — 58% overhead. At the 16 KB maximum it is 0.13%. AEAD makes encryption and authentication one call, so there is no order to get wrong and no branch between decrypting and checking. The next figure is what that sentence is worth.
Before AEAD the two were separate calls, and the order mattered enormously. Step a receiver through both orders and watch which one touches attacker-chosen bytes before it has checked the MAC:
Because MAC-then-encrypt decrypts and strips padding first, the time it takes to reject a forged record depends on the padding the attacker chose. That timing difference is a decryption oracle, and it is BEAST, POODLE and LUCKY13. Encrypt-then-MAC throws the forgery away before decryption runs; AEAD has no order to get wrong at all.
The one thing AEAD does not survive is a repeated nonce. Move to any record after the first, then switch the nonce from the sequence number to a fixed value:
As soon as two records share a nonce under one key they share a keystream, and XORing the two ciphertexts cancels it — the attacker recovers P₀ ⊕ Pᵢ without ever touching the key. Under GCM it is worse: the authentication subkey falls out too, and forgery becomes free. TLS 1.3 removes the choice by deriving the nonce from a sequence number that cannot repeat inside a connection.
Agreeing on a key in public
Both ends need the same symmetric key, and everything they say is readable by the whole path. Solving that in one exchange is the trick the rest of TLS is built on.
The obvious answer — encrypt a random key under the server's public key and send it — is what TLS 1.2 did and TLS 1.3 deleted outright. The reason it went is not that it was slow.
Start with what replaced it. Set the client's secret and the server's secret independently: only the two public values cross the wire, and both sides still land on the same number:
Notice the shared value appears in the two outer boxes and never in the middle one. An observer holding both public values would have to recover a or b to get there — multiplying what it has gives 5a+b, not 5ab. Real TLS runs this on Curve25519 rather than modulo 23, but the shape is exactly this.
All the security is the cost of that recovery, and it varies by five orders of magnitude across groups still in use. Step through them and read the work off a log₂ axis:
Because DH-1024 sits at 280 and the number field sieve does most of that work once per prime, an adversary who precomputes against a single widely shared 1024-bit prime reads every connection that used it. That is Logjam (Adrian et al., CCS 2015), and it is why TLS 1.3 dropped finite-field Diffie-Hellman. X25519 has no shared parameter to precompute against.
The property that survives a later theft is forward secrecy, and it is the whole reason key transport had to go. Choose how the session key was agreed, then drag the day the server's private key leaks:
Watch the bars behind the marker. With RSA key transport every recorded session back to day 0 decrypts, because the session key was itself encrypted to a key that outlives it. With ECDHE none of them do: the ephemeral secrets were never written anywhere, and the stolen key only ever signed. This is what “harvest now, decrypt later” is buying against.
One 32-byte shared secret is not enough on its own — the connection needs separate keys per direction and per phase, or a message from one can be replayed into another. Step down the key schedule:
Each arrow is one HKDF-Expand-Label call with a different label string, and each output is computationally independent of its siblings. That is why compromising an application traffic key does not hand over the resumption secret, and why KeyUpdate can rotate the traffic keys without a new handshake.
The last pressure on this axis is a machine that can run Shor's algorithm. The fix is already deployed and it is paid for in bytes — step through the groups and watch the ClientHello cross the segment limit:
X25519MLKEM768 carries a 1,216-byte key share, which pushes the ClientHello to 1,528 bytes — past the 1,460 an Ethernet MTU leaves after IP and TCP headers, so the hello becomes two packets and cannot complete before the second arrives. Chrome made it the default in version 131 (November 2024). One extra packet is the entire price, and it is paid because a session recorded today is still worth decrypting in 2040.
One round trip to encrypted records
TLS 1.2 needed two round trips before the first byte of HTTP could move. TLS 1.3 needs one, and the difference is a guess made in the first message.
The guess is the key share. The client picks the group it expects the server to support, generates an ephemeral key pair and sends the public half immediately — before it knows whether the server agrees, and paying a second round trip if it does not.
Step or play the handshake and watch which key each message is protected under: cleartext, then the handshake key, then the application key:
Notice how early the encryption starts. Only the two hellos are in the clear; by the fourth message the server is already encrypting, which is why in TLS 1.3 the certificate is not visible on the wire at all — a change from 1.2 that matters for censorship as much as for privacy. The client's Finished closes the handshake and the request rides the packet after it.
That saved round trip is the only thing the redesign bought, and its value is entirely the network's. Drag the round trip and read the time to the first byte of the response:
At the opening 80 ms, TLS 1.3 reaches the first byte in 240 ms against TLS 1.2's 320 — 80 ms saved out of a wait in which nothing useful has happened yet. Counted here: one round trip for TCP, one or two for TLS, one for the request and its answer. On a satellite link at 300 ms the saving is 300 ms.
Resumption can go further and put the request in the very first packet, with a caveat you have to design around rather than configure away. Choose the method the early data carries, then replay the captured packet:
Because the server has no per-connection state to check against, it cannot distinguish a replay from the original: drag to four replays and the account has been charged five times. TLS 1.3 does not fix this — real anti-replay would need state shared across every edge node in a CDN. So 0-RTT is allowed for GET and a full handshake is forced for anything that mutates.
The other thing a handshake must survive is an attacker rewriting it in flight to force a weaker choice. Pick a field to edit and compare the two versions:
Because CertificateVerify is a signature over the hash of every handshake byte so far, any edit makes the client's transcript disagree with the server's and the signature fails — decrypt_error (51), before one application byte moves. TLS 1.2 signed only the key-exchange parameters, and the unsigned remainder is the hole FREAK and Logjam walked through.
Finally, what all of this costs the machine answering. Set the connection rate and compare a full handshake with a resumed one:
Drag to ten thousand handshakes a second: a full handshake costs 1.09 cores of public-key work and a resumed one 0.80. Resumption saves the signature, not the key exchange, because TLS 1.3's default psk_dhe_ke still runs ECDHE to keep forward secrecy. psk_ke skips it and is nearly free, and gives that up.
Binding a key to a name
A perfectly encrypted channel to the wrong machine is worth nothing, so something has to tie a public key to a hostname. Every part of PKI exists to make that tie checkable.
A certificate is a signed statement: this key belongs to these names, until this date, for these uses. The client checks every clause, and the clauses are not interchangeable — failing one is a different bug from failing another.
Walk the six things a TLS client actually verifies and read what each one refuses:
Notice Common Name is not on the list. Chrome stopped reading it in 2017 and the rest followed; the hostname lives in the Subject Alternative Name extension and nowhere else. A certificate whose CN is right and whose SAN list is empty fails, and the error names a field the operator has never edited — which is why it costs an afternoon every time.
The hostname check is the one most often got wrong, because wildcards do not mean what they look like. Choose a hostname and compare it against the SAN entry:
A wildcard matches exactly one label. *.example.com covers api.example.com, does not cover a.b.example.com, and — the one that surprises people — does not cover example.com itself. The fourth hostname is the attack: api.example.com.evil.net ends in evil.net, which is why matching walks labels from the right rather than searching for a substring.
A leaf proves nothing on its own; it has to reach a key the client already holds. Step up the chain, then take the intermediate away:
Because clients ship only roots, the server must send every intermediate. Omitting one is the most common TLS misconfiguration there is, and it fails asymmetrically: browsers usually recover by fetching the issuer from the AIA extension, while curl, Java and Go do not. The site works in Chrome and breaks in CI, which is the worst possible way for it to break.
The intermediate exists because the root's private key is offline in a vault and signs perhaps once a decade. The leaf is the opposite — drag today across a 90-day certificate:
Watch the renewal window open at day 60. Thirty days of margin exists because renewal is an unattended job that fails quietly — a DNS change, a lapsed ACME account, a firewall rule — and thirty days is roughly how long it takes someone to notice. Past day 90 the client refuses with certificate_expired (45), loudly, which is the good case.
Short lifetimes are not hygiene, they are the revocation story. Pick a mechanism and drag the hours since a key was reported stolen:
Notice the top two bars are the same length. Plain OCSP soft-fails: if the responder is unreachable the client continues anyway, so an attacker who can block one HTTP request also gets the full 90 days. Stapling closes that by moving the fetch to the server; a six-day certificate closes it by not needing revocation at all, which is why the CA/Browser Forum voted in 2025 to cut the maximum lifetime to 47 days by 2029.
The last variation is running the same check in the other direction. Switch to mutual and step through both halves:
With server-only TLS the server knows the connection is private but not who is on it, so it falls back to a bearer token — and a bearer token in a log file is a credential. mTLS makes the client's private key the credential: it cannot be copied out of a header, and a mesh sidecar rotates it hourly. The cost is a second PKI to operate.
The record layer, where the bytes actually live
The handshake is over in a millisecond. Every byte after it goes through the record layer, whose two parameters are latency and throughput decisions in disguise.
A record is the unit of encryption. The sender fills one, seals it and writes it to the socket; the receiver can do nothing with it until all of it has arrived. That last clause is a latency budget, not a detail.
Start with what one record looks like on the wire, and what the sender can and cannot hide. Add padding and watch the length field:
Notice the header never changes colour. Type, version and length sit outside the AEAD, so an observer always learns exactly how many bytes each record carried — padding moves the number but cannot remove it. TLS 1.3 does hide the real content type by putting it inside the ciphertext, which is why the outer type reads application_data even for handshake messages.
The size the sender picks is a trade, and both ends of it are on screen at once. Drag the record size and read the three numbers it moves:
At 16 KB a one-megabyte response costs 64 records and 1.4 KB of overhead — 0.13%. Drag down to 64-byte records and the same response costs 16,384 records and 352 KB, a 34% tax. But the big record is the one that hurts: on a 10 Mbps link nothing inside a 16 KB record can be decrypted until 13.1 ms of it has arrived, which is why streaming servers use small records and bulk transfers use the maximum.
One key cannot run forever either, and the limit is not a policy but arithmetic. Choose the AEAD and the record size, then read how much data the key may protect:
RFC 8446 §5.5 caps AES-GCM at 224.5 records per key — about 23.7 million — because past that the birthday bound on the counter stops being negligible. At 16 KB records that is 362 GB, which a 10-gigabit connection reaches in about five minutes. KeyUpdate rotates a direction without a new handshake; ChaCha20-Poly1305 has no such record limit.
None of this hides the shape of the traffic. Step through what an observer still learns, with and without Encrypted Client Hello:
Four of the six survive TLS 1.3, and the interesting one is the hostname: SNI travels in the clear so a server holding many certificates can pick the right one before it has a key — which makes it the field every national firewall filters on. ECH encrypts it under a key published in DNS, leaving only the destination IP, and behind a CDN one IP serves millions of sites. Byte counts and timing survive both.
How it fails, and what it costs
Two things worth reasoning about cold: how a TLS failure reaches the caller, and where the CPU actually goes.
Almost every failure is loud by design — the protocol has no “ignore and continue”. The dangerous ones are the failures it never sees, and all of them are configuration.
Step through the six a real service hits and read what each returns:
The last row is the one to look for in review. curl -k, verify=False, InsecureSkipVerify: true — the handshake completes, the traffic really is encrypted, and there is no alert and no log line. Every other row aborts before an application byte moves.
The second silent class is a version policy nobody revisits. Drag the lowest version the server accepts and watch the published attacks switch off:
Notice TLS 1.2 still leaves two reachable — LUCKY13 through a CBC suite and Logjam through a small finite-field group — because 1.2 lets you configure those away rather than removing them. At TLS 1.3 the count is zero for a different reason: the algorithms are not in the specification at all.
Two more produce no alert because they never reach TLS. An HTTP-to-HTTPS redirect leaves the first request in cleartext, cookie included, unless HSTS is preloaded. And app-level AES over plain HTTP rebuilds every bug §02 walked through, with none of the review.
The cost question is simpler than its reputation. Set the line rate and choose whether the core has AES instructions:
At 1 Gbps AES-128-GCM costs 0.02 of a core; drag to 10 Gbps and it is 0.24. Take the instructions away and the same traffic costs 6.94 cores while ChaCha20-Poly1305 needs 1.14 — which is exactly why phones put ChaCha first in their cipher list and servers put AES first in theirs.