Adding Homemade TLS to a Homemade Web Server
Rebuilt
SubscribeSign in
Adding Homemade TLS to a Homemade Web Server<br>I combined my self-written web server and my self-written TLS project. The surprising part was how little the HTTP layer needed to know.
Dmytro Huz<br>Jul 04, 2026
Share
Original series: Building Your Own Web Server + Rebuilding TLS from ScratchThis article connects two earlier series. In the web-server series, I rebuilt the HTTP side from raw sockets up to routing and file serving. In the TLS series, I rebuilt the secure-channel side: key exchange, certificates, key derivation, and encrypted records.<br>This piece is where the two meet: what has to change when a web server stops receiving plaintext HTTP bytes and starts receiving encrypted TCP bytes?
Subscribe
I expected adding TLS to my web server to change almost everything.It did not.<br>The HTTP parser stayed boring. The router stayed boring. The file-serving code stayed boring.<br>The real change happened one layer below HTTP.<br>I had two separate learning projects before this:<br>a small web server built from scratch: sockets, HTTP parsing, location matching, file serving, and eventually a single-threaded event-loop server;
a small TLS-like secure channel built from scratch: ephemeral key exchange, certificate chains, server authentication, HKDF, AES-GCM records, and the difference between identity keys and session keys.
Putting them together led to one question:<br>What changes in the web server when the bytes coming from TCP are encrypted?
The useful answer is smaller than I expected:<br>TCP socket<br>TLS handshake / record layer<br>↓ decrypted plaintext bytes<br>HTTP parser / router / file serving<br>↓ plaintext HTTP response<br>TLS record protection<br>↓ encrypted bytes<br>TCP socket<br>That is the whole architecture of the companion project.<br>This is not production HTTPS. It does not implement the real TLS 1.3 wire format, and browsers will not connect to it with https://localhost:8443.<br>It is a learning project. The goal is to make the integration boundary visible.<br>Once that boundary becomes clear, HTTPS feels much less magical.
The plain web server had one assumption
Before TLS, the web server had a simple mental model.<br>A client connects over TCP. The server reads bytes from the socket. Those bytes are HTTP bytes. The server appends them to a per-connection buffer, tries to parse an HTTP request, matches the path, reads a file, and writes an HTTP response.<br>The flow looked like this:<br>accept TCP connection<br>read bytes from socket<br>append bytes to HTTP input buffer<br>parse HTTP request<br>match URL against location config<br>read file from root<br>write HTTP response<br>The buffer is important because TCP is a stream.<br>One recv() call can give you half a request. Or exactly one request. Or multiple requests joined together. The parser cannot assume that one socket read equals one HTTP request.<br>In my server, the parser consumes complete HTTP messages from a buffer. If the request is incomplete, the server waits for more bytes. If there are extra bytes after a complete request, they stay in the buffer for the next parse.<br>For plain HTTP, socket bytes and HTTP bytes are the same thing:<br>context.http_input.append(data)<br>self._handle_http_messages(context)<br>That line works only because the bytes from the socket are already plaintext HTTP.<br>TLS breaks that assumption.
After TLS, socket bytes are no longer HTTP bytes
Once TLS is added, the TCP socket no longer carries a readable HTTP request.<br>The client still wants to send this:<br>GET / HTTP/1.1<br>Host: localhost<br>Connection: close<br>But that request is not placed directly on the wire.<br>It is wrapped inside TLS records. After the handshake, those records are encrypted and authenticated. The bytes arriving at the server socket are now protocol bytes for the TLS layer, not text for the HTTP parser.<br>That is the shift:<br>plain listener:<br>TCP bytes are HTTP bytes
TLS listener:<br>TCP bytes are TLS records<br>TLS records decrypt into HTTP bytes<br>If the HTTP parser starts caring about certificates, keys, record sequence numbers, or AES-GCM tags, the abstraction has leaked.<br>The HTTP parser should parse HTTP.<br>The TLS layer should turn unsafe network bytes into authenticated plaintext bytes.
The integration point belongs below HTTP
Production servers use the same high-level boundary.<br>NGINX does not ask the HTTP parser to understand encrypted TLS records. A TLS implementation such as OpenSSL handles the connection security first. After that, the HTTP processing layer receives plaintext HTTP bytes.<br>My project copies that architecture in a smaller educational form:<br>Production-ish mental model:<br>TCP → OpenSSL/TLS state → plaintext HTTP → NGINX HTTP processing
This project:<br>TCP → ToyTLSServerConnection → plaintext HTTP → HTTPParser / route matcher<br>The analogy is architectural, not protocol-compatible.<br>The project uses a small TLS-like protocol so the moving pieces are possible to read in one sitting. The handshake messages are simplified. The record framing is simplified....