Skip to content

WebSocket Opening Handshake in C++

An HTTP/1.1 WebSocket connection begins with an HTTP request, then switches to a different message format on the same TCP connection. Build a small probe that verifies this transition before attempting any WebSocket frames.

Before you start

Complete Your First HTTPS Request, and review TCP message boundaries. Run commands on your Pi from the examples repository's network/ directory.

Outcome: send an Upgrade request, verify the server's 101 response and identify where HTTP headers end and WebSocket bytes begin.

A handshake probe, not a complete client

This lesson deliberately stops after the opening handshake and closes TCP without a WebSocket Close frame. That is not a clean WebSocket closing handshake. Do not use this probe as a production session or copy its teardown into a complete client. Frame encoding, masking and proper closure come next.

Follow the WebSocket opening exchange

sequenceDiagram accTitle: Change protocol without opening another socket accDescr: The C++ probe connects TCP and sends an HTTP Upgrade request with a fresh key. It validates the server's 101 response and accept value. The socket could now carry WebSocket frames, but this probe deliberately disconnects without a WebSocket closing handshake. participant C as C++ handshake probe participant S as Local fixture C->>S: One TCP connect C->>S: GET /ws, Upgrade, fresh key, version 13 S->>C: 101, Upgrade, Connection, calculated Accept Note over C: Validate all required fields Note over C,S: Same socket is now in WebSocket OPEN state Note over C: Preserve any already-read frame prefix C-->>S: Probe closes TCP without a Close frame

After a successful upgrade, future application data uses WebSocket frames, not another ordinary HTTP response body. The probe must not call shutdown(SHUT_WR) after sending its GET: the same write side would be needed for those frames.

This lesson uses ws:// on loopback, without TLS or authentication. wss:// protects the connection with TLS; its HTTP/1.1 Upgrade happens inside that verified connection. Using OpenSSL for a hash here does not encrypt the socket. Do not send secrets through this example.

Run the handshake probe

The OpenSSL development dependency from the TLS chapter is still required, but this executable links only its crypto component. No WebSocket library is used.

cmake --preset native-debug
cmake --build --preset native-debug --target ws_handshake_client --parallel 2

Terminal A:

timeout 35s python3 fixtures/ws_handshake.py 9004

It prints LISTEN 9004. In terminal B:

./build/native/debug/ws_handshake_client 9004

Expected output against this fixture:

1
2
3
status=101 upgrade=websocket
buffered_frame_bytes=0
buffered_prefix_hex=

The address is always 127.0.0.1, with the request authority localhost:9004 and path /ws: ws://localhost:9004/ws.

The fixture handles one attempt, observes the probe's TCP disconnect and exits. It explicitly reports that no WebSocket Close frame was exchanged. Restart it before each manual attempt. The outer timeout bounds the entire server process.

Read the request and verify the response

The probe sends these fields with CRLF line endings and a final empty line:

1
2
3
4
5
6
GET /ws HTTP/1.1
Host: localhost:9004
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: <fresh Base64-encoded 16-byte nonce>
Sec-WebSocket-Version: 13

A 101 status alone is not enough. The implementation checks:

Response field Required result
Status HTTP/1.1 101.
Upgrade websocket, case-insensitive.
Connection A case-insensitive Upgrade token, not merely that substring.
Sec-WebSocket-Accept The expected value for this request's key.

No subprotocol or extension is offered, so an unsolicited selection is rejected. This reduced parser also rejects repeated required fields, empty Connection-list elements, HTTP body-framing fields and non-ASCII/folded syntax. It is not a general HTTP header library.

A 200 response is still ordinary HTTP, not a successful WebSocket upgrade. The probe rejects other statuses rather than implementing redirects, login or negotiation retries.

Calculate Accept without confusing it with authentication

The expected value is:

Base64(SHA-1(key_text + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))

Use the Base64 key text, not the decoded nonce, as the prefix. The fixed GUID is public. This value demonstrates the requested protocol transition; it is not a password, secret or certificate check.

For the RFC's published example key dGhlIHNhbXBsZSBub25jZQ==, the result is s3pPLMBiTxaQ9kYGzzhZRbK+xOo=. The client does not reuse that example nonce: it calls RAND_bytes() for a fresh 16-byte value on each invocation. OpenSSL also supplies SHA-1 and Base64 operations; the sample does not implement cryptographic primitives.

SHA-1 is required by this existing handshake format, not proposed here as a new authentication design. If the configured crypto provider cannot supply it, the probe fails instead of substituting another algorithm.

Read the standalone C++ implementation

Follow four regions:

  1. Generate the key and expected Accept value.
  2. Connect and send the complete HTTP request through direct Linux socket calls.
  3. Accumulate bounded headers and validate the response.
  4. Separate any bytes after the empty line, report them and end this probe.
Full standalone client: app/ws-handshake/client.cpp
#include <algorithm>
#include <arpa/inet.h>
#include <array>
#include <cerrno>
#include <charconv>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <map>
#include <openssl/evp.h>
#include <openssl/rand.h>
#include <poll.h>
#include <stdexcept>
#include <string>
#include <string_view>
#include <sys/socket.h>
#include <system_error>
#include <unistd.h>

int main(int argc, char** argv) {
    int fd = -1;
    int status = 0;
    try {
        if (argc < 2 || argc > 3) {
            throw std::invalid_argument(
                "usage: ws_handshake_client port [handshake_ms]");
        }
        unsigned int port = 0;
        const std::string_view text = argv[1];
        const auto [end, invalid] =
            std::from_chars(text.data(), text.data() + text.size(), port);
        if (invalid != std::errc{} || end != text.data() + text.size() ||
            port == 0 || port > 65535) {
            throw std::invalid_argument("port must be 1..65535");
        }
        int handshake_ms = 3000;
        if (argc == 3) {
            const std::string_view budget = argv[2];
            const auto [last, error] = std::from_chars(
                budget.data(), budget.data() + budget.size(), handshake_ms);
            if (error != std::errc{} || last != budget.data() + budget.size() ||
                handshake_ms < 1 || handshake_ms > 10000) {
                throw std::invalid_argument("handshake_ms must be 1..10000");
            }
        }
        std::array<unsigned char, 16> nonce{};
        std::array<unsigned char, 25> encoded_key{};
        if (RAND_bytes(nonce.data(), nonce.size()) != 1 ||
            EVP_EncodeBlock(encoded_key.data(), nonce.data(), nonce.size()) !=
                24) {
            throw std::runtime_error("cannot generate WebSocket key");
        }
        const std::string key(reinterpret_cast<const char*>(encoded_key.data()),
                              24);
        const std::string challenge =
            key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
        std::array<unsigned char, EVP_MAX_MD_SIZE> digest{};
        std::size_t digest_size = 0;
        std::array<unsigned char, 29> encoded_accept{};
        if (EVP_Q_digest(nullptr, "SHA1", nullptr, challenge.data(),
                         challenge.size(), digest.data(), &digest_size) != 1 ||
            digest_size != 20 ||
            EVP_EncodeBlock(encoded_accept.data(), digest.data(), 20) != 28) {
            throw std::runtime_error("cannot compute WebSocket accept value");
        }
        const std::string expected_accept(
            reinterpret_cast<const char*>(encoded_accept.data()), 28);
        fd = ::socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
        if (fd < 0) {
            throw std::system_error(errno, std::generic_category(), "socket");
        }
        const auto deadline = std::chrono::steady_clock::now() +
                              std::chrono::milliseconds(handshake_ms);
        const auto check_deadline = [&] {
            if (std::chrono::steady_clock::now() >= deadline) {
                throw std::runtime_error("handshake deadline expired");
            }
        };
        const auto wait_for = [&](short events) {
            for (;;) {
                check_deadline();
                const auto remaining =
                    std::chrono::ceil<std::chrono::milliseconds>(
                        deadline - std::chrono::steady_clock::now())
                        .count();
                if (remaining <= 0) {
                    throw std::runtime_error("handshake deadline expired");
                }
                pollfd ready{fd, events, 0};
                const int result =
                    ::poll(&ready, 1, static_cast<int>(remaining));
                if (result < 0 && errno == EINTR) {
                    continue;
                }
                if (result < 0) {
                    throw std::system_error(errno, std::generic_category(),
                                            "poll");
                }
                check_deadline();
                if (result > 0) {
                    return; // The next socket operation inspects EOF/errors.
                }
            }
        };
        sockaddr_in server{};
        server.sin_family = AF_INET;
        server.sin_port = htons(static_cast<std::uint16_t>(port));
        server.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
        if (::connect(fd, reinterpret_cast<const sockaddr*>(&server),
                      sizeof(server)) < 0) {
            if (errno != EINPROGRESS) {
                throw std::system_error(errno, std::generic_category(),
                                        "connect");
            }
            wait_for(POLLOUT);
            int socket_error = 0;
            socklen_t size = sizeof(socket_error);
            if (::getsockopt(fd, SOL_SOCKET, SO_ERROR, &socket_error, &size) <
                0) {
                throw std::system_error(errno, std::generic_category(),
                                        "getsockopt");
            }
            if (socket_error != 0) {
                throw std::system_error(socket_error, std::generic_category(),
                                        "connect");
            }
        }
        const std::string request =
            "GET /ws HTTP/1.1\r\nHost: localhost:" + std::to_string(port) +
            "\r\nUpgrade: websocket\r\nConnection: "
            "Upgrade\r\nSec-WebSocket-Key: " +
            key + "\r\nSec-WebSocket-Version: 13\r\n\r\n";
        std::size_t offset = 0;
        while (offset < request.size()) {
            check_deadline();
            const auto sent = ::send(fd, request.data() + offset,
                                     request.size() - offset, MSG_NOSIGNAL);
            if (sent < 0 &&
                (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR)) {
                wait_for(POLLOUT);
                continue;
            }
            if (sent <= 0) {
                throw std::runtime_error("handshake send failed");
            }
            offset += static_cast<std::size_t>(sent);
        }
        // Do not shutdown(SHUT_WR): this same connection would carry frames.
        std::string response;
        std::array<char, 64> buffer{};
        std::size_t boundary = std::string::npos;
        while (boundary == std::string::npos) {
            check_deadline();
            const auto count = ::recv(fd, buffer.data(), buffer.size(), 0);
            if (count < 0 &&
                (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR)) {
                wait_for(POLLIN);
                continue;
            }
            if (count <= 0) {
                throw std::runtime_error(
                    "handshake ended before complete headers");
            }
            response.append(buffer.data(), static_cast<std::size_t>(count));
            boundary = response.find("\r\n\r\n");
            if ((boundary == std::string::npos && response.size() > 4096) ||
                (boundary != std::string::npos && boundary + 4 > 4096)) {
                throw std::runtime_error("handshake headers exceed 4096 bytes");
            }
        }
        const auto trim = [](std::string_view value) {
            while (!value.empty() &&
                   (value.front() == ' ' || value.front() == '\t')) {
                value.remove_prefix(1);
            }
            while (!value.empty() &&
                   (value.back() == ' ' || value.back() == '\t')) {
                value.remove_suffix(1);
            }
            return value;
        };
        const auto lower = [](std::string value) {
            for (char& c : value) {
                if (c >= 'A' && c <= 'Z') {
                    c += 'a' - 'A';
                }
            }
            return value;
        };
        const auto token_char = [](unsigned char c) {
            return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
                   (c >= '0' && c <= '9') ||
                   std::string_view("!#$%&'*+-.^_`|~").find(c) !=
                       std::string_view::npos;
        };
        const auto first_end = response.find("\r\n");
        const std::string_view status_line(response.data(), first_end);
        if (status_line.size() < 13 ||
            !status_line.starts_with("HTTP/1.1 101 ") ||
            !std::all_of(status_line.begin(), status_line.end(),
                         [](unsigned char c) {
                             return c == '\t' || (c >= 32 && c < 127);
                         })) {
            throw std::runtime_error("expected HTTP/1.1 status 101");
        }
        std::map<std::string, std::string> fields;
        std::size_t cursor = first_end + 2;
        while (cursor < boundary) {
            const auto line_end = response.find("\r\n", cursor);
            const std::string_view line(response.data() + cursor,
                                        line_end - cursor);
            const auto colon = line.find(':');
            if (colon == std::string_view::npos || colon == 0 ||
                !std::all_of(line.begin(), line.begin() + colon, token_char)) {
                throw std::runtime_error("invalid handshake header name");
            }
            const std::string name = lower(std::string(line.substr(0, colon)));
            const auto raw_value = line.substr(colon + 1);
            if (!std::all_of(raw_value.begin(), raw_value.end(),
                             [](unsigned char c) {
                                 return c == '\t' || (c >= 32 && c < 127);
                             })) {
                throw std::runtime_error("invalid handshake header value");
            }
            const auto value = trim(raw_value);
            if (name == "sec-websocket-protocol" ||
                name == "sec-websocket-extensions" ||
                name == "content-length" || name == "transfer-encoding") {
                throw std::runtime_error(
                    "unexpected negotiation or HTTP body framing");
            }
            if (name == "upgrade" || name == "connection" ||
                name == "sec-websocket-accept") {
                if (!fields.emplace(name, value).second) {
                    throw std::runtime_error("duplicate handshake field");
                }
            }
            cursor = line_end + 2;
        }
        if (!fields.contains("upgrade") || !fields.contains("connection") ||
            !fields.contains("sec-websocket-accept") ||
            lower(fields.at("upgrade")) != "websocket" ||
            fields.at("sec-websocket-accept") != expected_accept) {
            throw std::runtime_error(
                "invalid WebSocket upgrade or accept value");
        }
        bool upgrade_token = false;
        std::string_view options = fields.at("connection");
        for (;;) {
            const auto comma = options.find(',');
            const auto option = trim(options.substr(0, comma));
            if (option.empty() ||
                !std::all_of(option.begin(), option.end(), token_char)) {
                throw std::runtime_error("invalid Connection token list");
            }
            upgrade_token |= lower(std::string(option)) == "upgrade";
            if (comma == std::string_view::npos) {
                break;
            }
            options.remove_prefix(comma + 1);
        }
        if (!upgrade_token) {
            throw std::runtime_error("Connection lacks Upgrade token");
        }
        check_deadline();
        // Bytes read past the empty line belong to the future frame parser.
        const std::string frame_prefix = response.substr(boundary + 4);
        std::cout << "status=101 upgrade=websocket\nbuffered_frame_bytes="
                  << frame_prefix.size() << "\nbuffered_prefix_hex=";
        constexpr std::string_view hex = "0123456789abcdef";
        for (unsigned char c : frame_prefix) {
            std::cout << hex[c >> 4] << hex[c & 15];
        }
        std::cout << '\n';
        // This handshake-only probe ends here, without a WebSocket Close frame.
    } catch (const std::exception& error) {
        std::cerr << error.what() << '\n';
        status = 1;
    }
    if (fd >= 0) {
        ::close(fd);
    }
    return status;
}

Compile directly if you prefer:

1
2
3
c++ -std=c++20 -Wall -Wextra -Wpedantic -Werror \
    app/ws-handshake/client.cpp -o /tmp/ws_handshake_client \
    $(pkg-config --cflags --libs libcrypto)

The local lambdas only trim whitespace, normalize ASCII case, validate token characters and manage readiness/deadlines. The socket operations and descriptor cleanup remain visible in this single file.

Preserve the protocol boundary

The receive buffer is 64 bytes, but a TCP read can end anywhere:

[HTTP response headers][CRLF CRLF][possibly the first frame bytes]
                                   ^ no HTTP body starts here

Only the bytes through the empty line belong to the opening HTTP response. The code copies already-read bytes after that boundary to frame_prefix; a future frame parser must consume that prefix before reading more bytes from the socket.

The printed byte count is already-buffered data, not the total amount sent by the server or a frame length. The count may vary with read boundaries. This probe does not parse, validate or reply to those frames, and discards its local storage when it exits.

The handshake header limit is 4096 bytes including the terminating empty line. A last read can additionally contain up to 63 prefix bytes. One fixed 3000 ms default budget covers TCP connect, request send and header receipt/validation; local nonce/hash setup precedes it. An optional second argument accepts 1–10000 ms. Progress does not extend the deadline.

Verify the handshake, not just its status

Without starting the manual fixture, run:

ctest --preset native-debug -R network.ws_handshake --output-on-failure

The tests use Python's independent hashlib/base64 calculations for fresh client keys. They check exact request fields, no premature half-close or application data, completion while the peer remains open, split headers, token lists, whitespace, the exact header cap, coalesced frame-prefix bytes and invalid responses.

A hardcoded RFC example Accept must fail against a newly generated key. Four separate invocations must produce distinct keys; that is a regression check, not a statistical randomness audit. These are bounded loopback checks, not a WebSocket conformance suite.

Troubleshooting

  • Expected status 101: confirm the port runs the WebSocket fixture, not an ordinary HTTP server or the earlier TLS echo fixture.
  • Invalid upgrade or accept value: compare the key text and response calculation. Do not accept 101 without validating the fields.
  • Connection lacks Upgrade: split the field into comma-separated tokens; xupgrade is not Upgrade.
  • Handshake deadline expired: the peer may be silent or sending an incomplete header slowly. Raising the budget does not fix invalid negotiation.
  • Crypto or include error: confirm OpenSSL 3.0+ development headers/libraries are available, then reconfigure CMake.
  • Abnormal closure reported by another server: expected for this limited probe. A complete client needs WebSocket Close frames, not just raw TCP cleanup.

Check your understanding

  1. Does the upgrade create a second TCP connection?
  2. Can Connection: keep-alive, Upgrade satisfy the required token check?
  3. Why must the client calculate Accept from its own current key?
  4. If frame bytes arrive with the HTTP headers, where do they belong?
  5. Does linking OpenSSL crypto make this connection wss://?
Answers

No: the existing socket changes protocol. Yes, the list contains the Upgrade token. A static or unrelated Accept does not establish the requested transition. Bytes after the header terminator belong to frame parsing, not an HTTP body. Crypto helper calls alone do not establish TLS; this sample is plaintext ws.

Continue the course

Previous: Your First HTTPS Request · Next: Text Frames and Clean Closure

References: WebSocket opening handshake, OpenSSL digest API.

Donate