Skip to content
Home » All Posts » How to Build a C OpenSSL TLS Client–Server Over TCP and UDP

How to Build a C OpenSSL TLS Client–Server Over TCP and UDP

Introduction: What You’ll Build With C, OpenSSL, TCP and UDP

In this tutorial, I’ll walk through a practical C OpenSSL TLS client server example that uses plain TCP sockets as the transport. The goal is to keep the code minimal but realistic enough that you can reuse it as a starting point for your own secure network tools or services.

We’ll build two small programs:

  • TLS server in C: listens on a TCP port, accepts connections, performs a TLS handshake using OpenSSL, then exchanges simple application data (like a short message or echo).
  • TLS client in C: connects to the server over TCP, negotiates TLS, verifies the server certificate (in a minimal but clear way), and sends/receives data securely.

In my experience, the hard part when I first learned this wasn’t the TLS APIs themselves, but wiring them correctly on top of ordinary sockets and understanding the minimum OpenSSL setup needed (contexts, certificates, and error handling). That’s exactly what I focus on here: a clean, end-to-end example instead of scattered snippets.

All core concepts we use for TLS over TCP map directly to DTLS over UDP (the datagram-friendly version of TLS). While the final code in this article will stay with TCP for simplicity, I’ll highlight where things differ for UDP/DTLS so you can adapt the pattern later if you need secure datagrams.

By the end, you’ll have:

  • A self-contained server and client written in C using OpenSSL.
  • A working TLS handshake and encrypted message exchange over TCP.
  • A clear mental model of how to swap in DTLS APIs when you move to UDP.

Here’s roughly what the simplest client skeleton looks like before we add TLS; we’ll gradually wrap this with OpenSSL:

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

int main(void) {
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in addr = {0};
    addr.sin_family = AF_INET;
    addr.sin_port = htons(4433);
    inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);

    if (connect(sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        perror("connect");
        return 1;
    }

    /* This plain TCP connection will soon be upgraded to TLS with OpenSSL */

    close(sock);
    return 0;
}

Prerequisites, Tools, and Test Environment

Skills You Should Be Comfortable With

To get real value from this C OpenSSL TLS client server example, you should already be comfortable with basic C programming (functions, pointers, headers) and simple POSIX socket code (creating a socket, bind, listen, accept, connect). When I first wired TLS into my own socket code, the people who struggled most were the ones still fighting with file descriptors and sockaddr structures.

Libraries and Packages

You’ll need a recent OpenSSL and a standard C toolchain. On a Debian/Ubuntu-like system I usually install:

sudo apt-get install build-essential libssl-dev

On other platforms, use the equivalent packages or your OS’s preferred OpenSSL distribution. I’ve had the least friction when sticking to the system OpenSSL for development, then pinning versions later if I need reproducible builds.

OS and Compiler Setup

I’ll assume a Unix-like environment (Linux or macOS) with gcc or clang. The examples compile with a simple command such as:

gcc tls_server.c -o tls_server -lssl -lcrypto

Windows works too, but you’ll need either MSYS2, WSL, or a Visual Studio + vcpkg style setup, which is beyond this article’s scope.

Local Test Environment

We’ll run both the TLS server and client on localhost, using a non-privileged port (for example, 4433) so no special permissions are required. I usually keep a terminal for the server logs and another for the client so I can watch the handshake and message exchange side by side. If you plan to extend this over UDP later, using the same loopback setup keeps debugging simple while you experiment with DTLS.

Generating TLS Keys and Certificates for Your Test Server

Why You Need a Certificate for This C OpenSSL TLS Client Server Example

Before the C OpenSSL TLS client server example can do a real handshake, the server needs an identity: a private key and a certificate. In production, this comes from a trusted Certificate Authority (CA). For local testing, I almost always use a self-signed certificate so I can focus on the code without dealing with public CAs or DNS.

The server will load the private key and certificate from files, present the certificate to the client during the TLS handshake, and use the private key to prove it owns that certificate. The client can then optionally verify that certificate against a trust store, even if it’s just a custom CA file you keep next to your test binaries.

Generating TLS Keys and Certificates for Your Test Server - image 1

Step-by-Step: Create a Self-Signed Key and Certificate

Here’s the minimal sequence I use on my dev machines. Run these commands in an empty directory dedicated to your test certificates:

# 1. Generate a 2048-bit RSA private key
openssl genrsa -out server.key 2048

# 2. Create a self-signed certificate valid for 365 days
openssl req -new -x509 -key server.key -out server.crt \
    -days 365 -subj "/C=US/ST=Test/L=Local/O=DevOrg/OU=Dev/CN=localhost"

This gives you two important files:

  • server.key – the server’s private key (keep this secret, don’t commit it to git).
  • server.crt – the self-signed certificate the server will present to clients.

In my own practice, I always set CN=localhost (or a local test hostname) so that the client can later check it matches the host it connects to. For more advanced setups, you can add Subject Alternative Names, but for a focused tutorial this simple subject works fine.

Verifying the Files and Referencing Them in C

Before wiring these into C, I like to quickly inspect the certificate so I’m sure I generated what I expected:

openssl x509 -in server.crt -noout -text | less

On the C side, the server will load these files into an OpenSSL context. The calls look roughly like this, which we’ll flesh out in the server section:

SSL_CTX *ctx = SSL_CTX_new(TLS_server_method());
if (!ctx) {
    /* handle error */
}

if (SSL_CTX_use_certificate_file(ctx, "server.crt", SSL_FILETYPE_PEM) <= 0) {
    /* handle error */
}

if (SSL_CTX_use_PrivateKey_file(ctx, "server.key", SSL_FILETYPE_PEM) <= 0) {
    /* handle error */
}

Once those calls succeed, your TLS server has everything it needs to identify itself during the handshake. Later, when we tighten up client verification, we’ll also show how to tell the client to trust this same self-signed certificate or a small custom CA you generate. For more background on key sizes, algorithms, and certificate fields, you can dig into Create a Self-Signed TLS Certificate | Linode Docs.

Understanding the Basic C OpenSSL TLS Client Server Flow

From Plain TCP Sockets to TLS-Encrypted Channels

At a high level, a C OpenSSL TLS client server example starts exactly like a normal TCP program: the server binds and listens on a port, and the client connects to it. The main difference is what happens after the TCP connection is established: instead of sending raw bytes with send and recv, both sides perform a TLS handshake and then exchange encrypted data using OpenSSL’s SSL_read and SSL_write.

When I first migrated an existing TCP service to TLS, the key lesson was that you don’t throw away your socket code; you wrap it. Your existing connect/accept logic stays almost the same, but the application-level protocol now rides over a secure TLS channel.

Understanding the Basic C OpenSSL TLS Client Server Flow - image 1

Typical TLS Lifecycle on the Server and Client

On the server side, the lifecycle looks like this:

  • Initialize OpenSSL library state (algorithms, error strings).
  • Create an SSL_CTX with a server method (for example, TLS_server_method()).
  • Load the certificate and private key into that context.
  • Create and bind a TCP listening socket, then accept connections.
  • For each accepted socket, create an SSL* object, attach the file descriptor, and call SSL_accept() to run the handshake.
  • Use SSL_read() and SSL_write() for application data.
  • Call SSL_shutdown(), free the SSL, and close the socket when done.

The client side is symmetrical:

  • Initialize OpenSSL and create an SSL_CTX with TLS_client_method().
  • Configure trust (CA file or self-signed cert) and hostname verification.
  • Create and connect a TCP socket to the server.
  • Create an SSL object, attach the socket, and call SSL_connect().
  • Exchange data with SSL_read() / SSL_write().
  • Shutdown and clean up like the server.

In C, the wrapping step is the real pivot point. Here’s a minimal sketch of how I usually bridge a connected socket into OpenSSL on the client side:

SSL_CTX *ctx = SSL_CTX_new(TLS_client_method());
/* configure trust etc. on ctx */

int sock = /* connect() to server over TCP */;

SSL *ssl = SSL_new(ctx);
SSL_set_fd(ssl, sock);

if (SSL_connect(ssl) <= 0) {
    /* handle handshake error */
}

/* Now use SSL_read/SSL_write instead of recv/send */

SSL_shutdown(ssl);
SSL_free(ssl);
close(sock);
SSL_CTX_free(ctx);

How UDP and DTLS Change the Picture

UDP introduces a different set of constraints: it’s message-oriented, unreliable, and packets can be lost or reordered. TLS assumes a reliable byte stream, so you can’t just drop it on top of UDP. That’s where DTLS (Datagram TLS) comes in.

Conceptually, DTLS follows the same pattern as TLS over TCP: initialize OpenSSL, create a context with a DTLS method, associate it with a UDP socket, perform a handshake, then encrypt and decrypt application data. In my experience, the real differences show up in the details: you use DTLS_method()/DTLS_server_method(), handle timeouts and retransmissions, and pay more attention to client addresses and cookies for anti-DoS.

For this article, we’ll stay with TLS over TCP to keep the C examples clear and focused. But if you understand the lifecycle above, moving to DTLS mainly means swapping in the DTLS-specific APIs and being careful with non-blocking I/O and retransmission logic, rather than learning a completely new security model.

Step 1: Initializing OpenSSL in Your C Program

One-Time OpenSSL Library Initialization

Every solid C OpenSSL TLS client server example starts by setting up the OpenSSL library state once, before you create any TLS objects. In older OpenSSL versions, this meant calling several initialization functions; in newer 1.1.x+ builds most of that is done for you automatically. I still like to centralize the setup in a single helper so I know exactly what’s happening.

Here’s a small initialization function I commonly drop into my projects:

#include <openssl/ssl.h>
#include <openssl/err.h>

static void openssl_init(void)
{
    /* For OpenSSL 1.1.0+ this is mostly automatic, but these calls are safe */
    SSL_load_error_strings();
    OpenSSL_add_ssl_algorithms();
}

static void openssl_cleanup(void)
{
    EVP_cleanup();
}

In my own code, I call openssl_init() once at process startup (before creating any SSL_CTX), and openssl_cleanup() once before exit. Centralizing this avoids the “random crashes” I’ve seen when people mix and match initialization calls in multiple modules.

Creating an SSL_CTX for a TLS Server

The SSL_CTX is your configuration container. The server creates one context, configures it with certificates and options, then spawns per-connection SSL objects from it. Think of it as the template for all secure connections on that side.

Here’s a typical server-side setup:

SSL_CTX *create_server_ctx(void)
{
    const SSL_METHOD *method;
    SSL_CTX *ctx;

    method = TLS_server_method();
    ctx = SSL_CTX_new(method);
    if (!ctx) {
        ERR_print_errors_fp(stderr);
        return NULL;
    }

    /* Load certificate and private key created earlier */
    if (SSL_CTX_use_certificate_file(ctx, "server.crt", SSL_FILETYPE_PEM) <= 0) {
        ERR_print_errors_fp(stderr);
        SSL_CTX_free(ctx);
        return NULL;
    }

    if (SSL_CTX_use_PrivateKey_file(ctx, "server.key", SSL_FILETYPE_PEM) <= 0) {
        ERR_print_errors_fp(stderr);
        SSL_CTX_free(ctx);
        return NULL;
    }

    if (!SSL_CTX_check_private_key(ctx)) {
        fprintf(stderr, "Private key does not match the certificate public key\n");
        SSL_CTX_free(ctx);
        return NULL;
    }

    /* Basic security hardening: disable legacy protocols */
    SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION);

    return ctx;
}

When I first started, I often forgot the SSL_CTX_check_private_key() call and then spent time chasing mysterious handshake failures. Adding that check early has saved me plenty of debugging sessions.

Creating an SSL_CTX for a TLS Client

The client uses a similar pattern but with a client-specific method and trust configuration. In development, I often point the client at my self-signed server certificate so it can verify the TLS peer correctly.

SSL_CTX *create_client_ctx(void)
{
    const SSL_METHOD *method;
    SSL_CTX *ctx;

    method = TLS_client_method();
    ctx = SSL_CTX_new(method);
    if (!ctx) {
        ERR_print_errors_fp(stderr);
        return NULL;
    }

    /* Trust store: for self-signed testing, trust server.crt directly */
    if (!SSL_CTX_load_verify_locations(ctx, "server.crt", NULL)) {
        ERR_print_errors_fp(stderr);
        SSL_CTX_free(ctx);
        return NULL;
    }

    SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, NULL);
    SSL_CTX_set_verify_depth(ctx, 4);

    SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION);

    return ctx;
}

In my experience, wiring up verification early—even with a local self-signed cert—forces you into good habits. It’s easy to sprinkle SSL_VERIFY_NONE everywhere “just for testing” and then accidentally ship that into production.

Putting Initialization Together in main()

Once those helpers exist, the top of main() for both server and client stays clean and predictable. Here’s a sketch of a server entry point that shows the order I rely on:

int main(void)
{
    openssl_init();

    SSL_CTX *ctx = create_server_ctx();
    if (!ctx) {
        fprintf(stderr, "Failed to create server SSL_CTX\n");
        return 1;
    }

    /* Create TCP listening socket, then for each accepted connection: */
    /*
    int client_fd = accept(...);
    SSL *ssl = SSL_new(ctx);
    SSL_set_fd(ssl, client_fd);

    if (SSL_accept(ssl) <= 0) {
        ERR_print_errors_fp(stderr);
        // handle error
    } else {
        // use SSL_read/SSL_write
    }

    SSL_shutdown(ssl);
    SSL_free(ssl);
    close(client_fd);
    */

    SSL_CTX_free(ctx);
    openssl_cleanup();
    return 0;
}

This structure keeps the OpenSSL lifecycle easy to reason about: initialize once, create a reusable context, spawn per-connection SSL objects, and clean everything up at the end. If you want to go deeper into advanced context options (cipher suites, session tickets, ALPN, etc.), an OpenSSL SSL_CTX configuration guide is a great next step after you’ve mastered this core setup.

Step 2: Implementing the TCP Server and Wrapping It With TLS

Building a Minimal Blocking TCP Server

Now that the OpenSSL context is ready, the next step in this C OpenSSL TLS client server example is to stand up a simple TCP server. I like to get this working in plain form first, then layer TLS on top so I can clearly see where things change.

Here’s a compact blocking TCP server that listens on port 4433 and accepts a single client:

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

static int create_listen_socket(int port)
{
    int sockfd;
    struct sockaddr_in addr;
    int opt = 1;

    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        perror("socket");
        return -1;
    }

    if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
        perror("setsockopt");
        close(sockfd);
        return -1;
    }

    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port = htons(port);

    if (bind(sockfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        perror("bind");
        close(sockfd);
        return -1;
    }

    if (listen(sockfd, 1) < 0) {
        perror("listen");
        close(sockfd);
        return -1;
    }

    return sockfd;
}

This is the same pattern I use for most small test servers: keep it blocking and single-threaded at first so that TLS issues are easy to isolate before layering on concurrency or async I/O.

Step 2: Implementing the TCP Server and Wrapping It With TLS - image 1

Upgrading an Accepted Socket to TLS With OpenSSL

Once a client connects, the only real difference from a non-TLS server is that we bind the accepted file descriptor to an SSL object and perform a handshake with SSL_accept(). After that, the server uses SSL_read() and SSL_write() instead of recv and send.

Here’s a complete example that accepts a client, runs the TLS handshake, echoes a line of text, and then shuts down cleanly:

#include <openssl/ssl.h>
#include <openssl/err.h>

extern void openssl_init(void);
extern void openssl_cleanup(void);
extern SSL_CTX *create_server_ctx(void);
extern int create_listen_socket(int port);

static void handle_client(SSL_CTX *ctx, int client_fd)
{
    SSL *ssl = SSL_new(ctx);
    if (!ssl) {
        ERR_print_errors_fp(stderr);
        close(client_fd);
        return;
    }

    SSL_set_fd(ssl, client_fd);

    if (SSL_accept(ssl) <= 0) {
        fprintf(stderr, "TLS handshake failed\n");
        ERR_print_errors_fp(stderr);
        SSL_shutdown(ssl);
        SSL_free(ssl);
        close(client_fd);
        return;
    }

    printf("TLS handshake successful\n");

    const char *reply = "Hello over TLS!\n";
    if (SSL_write(ssl, reply, strlen(reply)) <= 0) {
        fprintf(stderr, "SSL_write failed\n");
        ERR_print_errors_fp(stderr);
    }

    SSL_shutdown(ssl);
    SSL_free(ssl);
    close(client_fd);
}

int main(void)
{
    openssl_init();

    SSL_CTX *ctx = create_server_ctx();
    if (!ctx) {
        return 1;
    }

    int listen_fd = create_listen_socket(4433);
    if (listen_fd < 0) {
        SSL_CTX_free(ctx);
        openssl_cleanup();
        return 1;
    }

    printf("Listening on port 4433...\n");

    struct sockaddr_in client_addr;
    socklen_t client_len = sizeof(client_addr);
    int client_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_len);
    if (client_fd < 0) {
        perror("accept");
        close(listen_fd);
        SSL_CTX_free(ctx);
        openssl_cleanup();
        return 1;
    }

    handle_client(ctx, client_fd);

    close(listen_fd);
    SSL_CTX_free(ctx);
    openssl_cleanup();
    return 0;
}

In my own debugging workflow, I like to add a few log messages right before and after SSL_accept() so I can immediately see whether failures happen in the socket layer or at the TLS handshake layer.

Error Handling and Cleanup Considerations

With TLS in the mix, proper cleanup becomes more important. The order I stick to is:

  • Call SSL_shutdown() to send/receive the TLS close_notify alert.
  • Free the SSL object with SSL_free().
  • Close the underlying socket descriptor with close().

If SSL_accept() or SSL_write() fails, I always dump the error stack via ERR_print_errors_fp(stderr). When I first built this, I tried to interpret error codes manually and wasted time; reading the OpenSSL error queue directly usually points me straight at certificate or protocol issues.

Extending the Server for Multiple Clients

This example handles just one connection for simplicity, but you can expand it once you’re comfortable:

  • Wrap the accept() and handle_client() pair in a loop.
  • Use fork(), threads, or an event loop to manage multiple TLS sessions.
  • Reuse the same SSL_CTX for all connections; only SSL objects are per-client.

When I scaled this pattern up in real projects, keeping a single, well-configured SSL_CTX and treating each SSL * as disposable per-connection state made the codebase much easier to reason about and test.

Step 3: Implementing the TCP Client and Connecting via TLS

Creating a Simple Blocking TCP Client

With the server in place, the next piece of this C OpenSSL TLS client server example is a minimal TCP client we can then upgrade to TLS. I like to keep the raw socket connect logic tiny and well-isolated so that the TLS parts are easy to see.

Here’s a helper that connects to localhost:4433 and returns a connected socket:

#include <arpa/inet.h>
#include <netdb.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

static int tcp_connect(const char *host, const char *port)
{
    struct addrinfo hints, *res, *rp;
    int sock = -1;

    memset(&hints, 0, sizeof(hints));
    hints.ai_family   = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;

    if (getaddrinfo(host, port, &hints, &res) != 0) {
        perror("getaddrinfo");
        return -1;
    }

    for (rp = res; rp != NULL; rp = rp->ai_next) {
        sock = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
        if (sock == -1)
            continue;
        if (connect(sock, rp->ai_addr, rp->ai_addrlen) == 0)
            break;  /* success */
        close(sock);
        sock = -1;
    }

    freeaddrinfo(res);
    return sock;  /* -1 on failure */
}

In my own projects I reuse this same pattern everywhere; having a stable, boring TCP connect helper makes TLS-specific bugs much easier to spot.

Step 3: Implementing the TCP Client and Connecting via TLS - image 1

Wrapping the TCP Connection With TLS on the Client

Once the TCP connection is established, the client creates an SSL object from its SSL_CTX, associates the socket file descriptor, and calls SSL_connect() to perform the TLS handshake.

Here’s a complete example that connects to the server, runs the handshake, reads the greeting, and prints it to stdout:

#include <openssl/ssl.h>
#include <openssl/err.h>

extern void openssl_init(void);
extern void openssl_cleanup(void);
extern SSL_CTX *create_client_ctx(void);
extern int tcp_connect(const char *host, const char *port);

static int tls_client_session(SSL_CTX *ctx, const char *host, const char *port)
{
    int sock = tcp_connect(host, port);
    if (sock < 0) {
        fprintf(stderr, "Failed to connect to %s:%s\n", host, port);
        return 1;
    }

    SSL *ssl = SSL_new(ctx);
    if (!ssl) {
        ERR_print_errors_fp(stderr);
        close(sock);
        return 1;
    }

    SSL_set_fd(ssl, sock);

    /* Optional but good practice: set the SNI and hostname for verification */
    if (!SSL_set_tlsext_host_name(ssl, host)) {
        ERR_print_errors_fp(stderr);
    }

    if (SSL_connect(ssl) <= 0) {
        fprintf(stderr, "TLS handshake failed\n");
        ERR_print_errors_fp(stderr);
        SSL_shutdown(ssl);
        SSL_free(ssl);
        close(sock);
        return 1;
    }

    printf("TLS handshake successful\n");

    char buf[1024];
    int n = SSL_read(ssl, buf, sizeof(buf) - 1);
    if (n <= 0) {
        fprintf(stderr, "SSL_read failed\n");
        ERR_print_errors_fp(stderr);
    } else {
        buf[n] = '\0';
        printf("Server says: %s", buf);
    }

    SSL_shutdown(ssl);
    SSL_free(ssl);
    close(sock);
    return 0;
}

When I first wired this up, most of my handshake failures came from trust issues (wrong CA file, mismatched hostname), so I’ve learned to always log both a friendly error and the OpenSSL error stack.

Verifying the Server Certificate and Hostname

Earlier, we configured the SSL_CTX to trust the server certificate. The last piece is checking that the certificate’s subject actually matches the hostname we connected to. OpenSSL 1.1.0+ makes this fairly straightforward.

Here’s one way to enforce hostname verification immediately after a successful SSL_connect():

#include <openssl/x509v3.h>

static int verify_server_hostname(SSL *ssl, const char *host)
{
    X509 *cert = SSL_get_peer_certificate(ssl);
    if (!cert) {
        fprintf(stderr, "No server certificate presented\n");
        return 0;
    }

    X509_free(cert); /* SSL_get_verify_result uses internal cert copy */

    long res = SSL_get_verify_result(ssl);
    if (res != X509_V_OK) {
        fprintf(stderr, "Certificate verification error: %ld\n", res);
        return 0;
    }

    /* For full hostname checks, use X509_check_host if available */
#ifdef X509_CHECK_FLAG_NO_PARTIAL_WILD
    if (!X509_check_host(SSL_get_peer_certificate(ssl), host, 0, 0, NULL)) {
        fprintf(stderr, "Hostname verification failed for %s\n", host);
        return 0;
    }
#endif

    return 1;
}

In local testing with CN=localhost, this can be as simple as checking that you actually got a certificate and that SSL_get_verify_result() is X509_V_OK. In my own setups, I start with that minimal check and only add full hostname rules once I know the basics are solid.

Putting the TLS Client Together in main()

With all the helpers in place, the client’s main() stays very small and predictable. That’s been crucial for me when I circle back months later to debug or extend the client.

int main(void)
{
    const char *host = "localhost";
    const char *port = "4433";

    openssl_init();

    SSL_CTX *ctx = create_client_ctx();
    if (!ctx) {
        fprintf(stderr, "Failed to create client SSL_CTX\n");
        openssl_cleanup();
        return 1;
    }

    int rc = tls_client_session(ctx, host, port);

    SSL_CTX_free(ctx);
    openssl_cleanup();
    return rc;
}

Once this client can reliably connect, complete the handshake, and print the server’s greeting, you’ve closed the loop of a functional, end-to-end TLS connection. From here, I usually start iterating on the application protocol itself—knowing the security layer is already behaving as expected.

Step 4: Adapting the Example to UDP Using DTLS

Key Differences Between TLS over TCP and DTLS over UDP

Once the TCP side of this C OpenSSL TLS client server example is working, moving to UDP is mostly about swapping in DTLS primitives and respecting the fact that UDP is unreliable and message-oriented. When I first did this in a real project, the big mindset shift was that there’s no “connection” at the socket layer: you’re dealing with individual datagrams and client addresses instead of a dedicated stream per peer.

Conceptually, DTLS gives you TLS-like security (certificates, handshakes, encryption) but runs over UDP. You still use an SSL_CTX and SSL objects, but the methods, socket setup, and some options change.

Creating DTLS Contexts and UDP Sockets

The first mechanical change is to use DTLS methods when creating contexts. The certificate configuration stays almost the same as for TCP:

SSL_CTX *create_dtls_server_ctx(void)
{
    const SSL_METHOD *method = DTLS_server_method();
    SSL_CTX *ctx = SSL_CTX_new(method);
    if (!ctx) {
        ERR_print_errors_fp(stderr);
        return NULL;
    }

    /* Reuse the same cert/key as the TLS server */
    if (SSL_CTX_use_certificate_file(ctx, "server.crt", SSL_FILETYPE_PEM) <= 0 ||
        SSL_CTX_use_PrivateKey_file(ctx, "server.key", SSL_FILETYPE_PEM) <= 0 ||
        !SSL_CTX_check_private_key(ctx)) {
        ERR_print_errors_fp(stderr);
        SSL_CTX_free(ctx);
        return NULL;
    }

    SSL_CTX_set_min_proto_version(ctx, DTLS1_2_VERSION);
    return ctx;
}

SSL_CTX *create_dtls_client_ctx(void)
{
    const SSL_METHOD *method = DTLS_client_method();
    SSL_CTX *ctx = SSL_CTX_new(method);
    if (!ctx) {
        ERR_print_errors_fp(stderr);
        return NULL;
    }

    if (!SSL_CTX_load_verify_locations(ctx, "server.crt", NULL)) {
        ERR_print_errors_fp(stderr);
        SSL_CTX_free(ctx);
        return NULL;
    }

    SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, NULL);
    SSL_CTX_set_min_proto_version(ctx, DTLS1_2_VERSION);
    return ctx;
}

On the socket side, both client and server use SOCK_DGRAM instead of SOCK_STREAM. In my experience, keeping this setup as close as possible to the TCP helpers (same ports, same error handling) makes side-by-side debugging much easier.

Binding DTLS to UDP and Handling the Handshake

With DTLS, the server typically creates a UDP socket bound to a port, then associates an SSL object with that socket and a specific client address. OpenSSL provides a BIO layer to glue datagram sockets to DTLS; the pattern looks roughly like this:

#include <openssl/bio.h>

static SSL *dtls_server_accept(SSL_CTX *ctx, int udp_fd,
                               struct sockaddr_storage *client_addr,
                               socklen_t *client_len)
{
    /* First receive a packet to learn the client address */
    char buf[1500];
    *client_len = sizeof(*client_addr);
    ssize_t n = recvfrom(udp_fd, buf, sizeof(buf), MSG_PEEK,
                          (struct sockaddr *)client_addr, client_len);
    if (n <= 0) {
        perror("recvfrom");
        return NULL;
    }

    BIO *bio = BIO_new_dgram(udp_fd, BIO_NOCLOSE);
    if (!bio) return NULL;

    BIO_ctrl(bio, BIO_CTRL_DGRAM_SET_CONNECTED, 0, client_addr);

    SSL *ssl = SSL_new(ctx);
    if (!ssl) {
        BIO_free(bio);
        return NULL;
    }

    SSL_set_bio(ssl, bio, bio);

    if (SSL_accept(ssl) <= 0) {
        ERR_print_errors_fp(stderr);
        SSL_free(ssl);
        return NULL;
    }

    return ssl;
}

This is simplified, but it shows the flow I follow: peek one packet to learn the client, create a datagram BIO, tie it to an SSL, and then call SSL_accept(). On the client side, you similarly wrap a connected UDP socket in a datagram BIO and call SSL_connect() instead.

Practical Gotchas When Porting Your Example to DTLS

Two issues bit me the first time I ported a TCP-only design to DTLS:

  • Timeouts and retransmissions: DTLS handshakes may need explicit timeouts. Using non-blocking sockets or select()/poll() around DTLS I/O gives you much better control.
  • Message boundaries: Because UDP is message-based, I always design the application protocol around discrete packets instead of assuming a continuous byte stream like TLS over TCP.

The good news is that once your C code cleanly separates “transport” (TCP vs UDP) from “security” (TLS vs DTLS), extending the existing client–server example to UDP mostly becomes a matter of plugging in DTLS-specific context creation and BIO wiring. For more depth on production-ready patterns, an OpenSSL Wiki: DTLS Server and Client Examples in C is worth exploring after you’ve experimented with a small prototype.

Testing, Debugging, and Hardening Your C OpenSSL TLS Example

Basic Connectivity and Functional Testing

Once both sides of your C OpenSSL TLS client server example compile, I like to start with the simplest end-to-end test: run the server, run the client, and confirm that you see the expected greeting and a clean exit on both sides. After that, I bring in openssl s_client as a second opinion.

For example, with the server listening on port 4433:

openssl s_client -connect localhost:4433 -servername localhost -CAfile server.crt

If the handshake completes and you see the certificate chain plus your greeting, you know the server is behaving well enough for other TLS stacks, not just your own client.

Testing, Debugging, and Hardening Your C OpenSSL TLS Example - image 1

Debugging Common TLS and Certificate Issues

Most of the real-world pain I’ve seen has been around certificates and trust. On both client and server, make sure to:

  • Call ERR_print_errors_fp(stderr) immediately after any failed OpenSSL call.
  • Verify that the server certificate’s CN or SAN matches the hostname you connect to.
  • Confirm that the client’s SSL_CTX_load_verify_locations points to the correct CA or self-signed certificate.

When debugging, I sometimes temporarily relax checks (for instance, using a self-signed cert with a very simple DN) but I always put verification back once the handshake behavior is understood, otherwise it’s too easy to “fix” bugs by disabling security.

Using Traces and Tools to Inspect Handshakes

If logs aren’t enough, I turn to packet captures and OpenSSL’s built-in tracing. Capturing traffic with tcpdump or Wireshark lets you see whether the handshake is even reaching the other side and which alert codes are being sent.

You can also enable verbose debug output directly in your code:

#include <openssl/ssl.h>

static void enable_openssl_debug(void)
{
    SSL_trace(false); /* placeholder: use SSL_CTX_set_info_callback in real code */
}

In practice I use SSL_CTX_set_info_callback() to log handshake state changes; it’s a bit noisy, but when I was first learning, seeing each handshake step in the logs helped me connect the theory to what was actually happening on the wire. A dedicated A Developer’s Guide to openssl_client – Spectral can be invaluable once you start chasing down more subtle failures.

Basic Hardening for Production-Like Setups

To move closer to a production posture, I normally add a few straightforward hardening steps:

  • Protocol versions: Set a minimum of TLS 1.2 (or 1.3 if supported) on both client and server via SSL_CTX_set_min_proto_version().
  • Cipher suites: Restrict to modern ciphers, avoiding legacy CBC or NULL suites.
  • Verify peers: Never ship with SSL_VERIFY_NONE; enforce certificate verification and hostname checks.
  • Secure key handling: Lock down file permissions on private keys and avoid hard-coding paths in sample code you later reuse.

In my experience, baking these into the example early pays off later—future refactors tend to copy existing patterns, so if those patterns are already safe, you’re far less likely to regress into “demo-grade” security when the code grows.

Conclusion and Next Steps for Advanced C OpenSSL TLS Projects

What You’ve Built

At this point, you’ve walked through a complete C OpenSSL TLS client server example: initializing OpenSSL, configuring SSL_CTX for both sides, building a blocking TCP server wrapped with TLS, and implementing a TCP client that verifies the server certificate and exchanges encrypted data. You also saw how the same patterns extend to UDP with DTLS, which is exactly how I’ve approached secure datagram protocols in my own work—start from a solid TLS baseline, then adapt for datagrams.

Ideas for Your Next TLS-Focused Projects

From here, there are several natural directions to grow this foundation:

  • Turn the echo-style example into a small, framed application protocol with request/response messages.
  • Add concurrency with threads or an event loop to support many simultaneous TLS or DTLS clients.
  • Experiment with mutual TLS (client certificates) for stronger authentication.
  • Layer these primitives into a real-world service: a metrics collector, chat server, or control channel for another system.

In my experience, the biggest leap comes when you stop treating TLS as a black box and start treating it as a first-class part of your design—using the patterns here, you now have the tools to do exactly that.

1 Comment on this post

  1. Well done, Cary!
    I’ve been wanting to explore low-level openssl coding for a while and your introduction here is the perfect tutorial for getting the basic understanding as quickly as possible. I share your enthusiasm for simplifying, and have always pushed K.I.S.S. to my students and colleagues. Thanks for the fat-free code and concise explanations!

Join the conversation

Your email address will not be published. Required fields are marked *