Skip to main content

Experimental DTLS Support in Node.js

Experimental DTLS Support in Node.js

I decided to take a short break from the QUIC work to implement something that has been on my list for a while: a native DTLS API for Node.js. The pull request is up and the basic API is functional. It is very experimental -- I built it mostly for the enjoyment of it -- but it addresses a real gap in the platform and I think the API turned out well.

DTLS is the Datagram Transport Layer Security protocol, standardized most recently as RFC 9147. It's a security layer for UDP, designed for datagram transport where packets may arrive out of order, be duplicated, or be lost entirely.

The implementation is built on OpenSSL's built-in DTLS support and requires no additional dependencies beyond what Node.js already ships. The API design is quite intentionally inspired by the node:quic module.

Why DTLS matters

DTLS shows up wherever you need encrypted communication over UDP:

  • WebRTC: The DTLS handshake is how WebRTC peers establish the keying material for SRTP media encryption. Every WebRTC call starts with a DTLS exchange.

  • CoAP: The Constrained Application Protocol, used heavily in IoT, relies on DTLS for securing communication between resource-constrained devices.

  • Gaming and real-time protocols: Any UDP-based protocol that needs encryption but cannot tolerate the head-of-line blocking inherent in TCP+TLS is a candidate for DTLS.

  • VPN tunnels: Several VPN protocols use DTLS as their transport security layer to avoid the latency penalties of TCP-based tunnels.

DTLS vs TLS

If you are familiar with TLS, DTLS will feel conceptually similar, but there are important differences driven by the underlying UDP transport:

  • No stream guarantees: TLS operates over TCP, which guarantees ordered, reliable delivery. DTLS operates over UDP, which guarantees neither. Messages may arrive out of order or not at all. DTLS preserves datagram semantics -- each send() corresponds to one datagram.

  • One socket, many peers: A single UDP socket can communicate with multiple remote peers. The DTLSEndpoint manages this multiplexing, routing incoming packets to the correct DTLSSession based on the source address.

  • Cookie exchange: DTLS servers use a stateless cookie mechanism (HelloVerifyRequest) to prevent denial-of-service amplification attacks. Because UDP source addresses can be spoofed, the server needs to verify that the client actually controls the address it claims to be using before committing resources to a handshake. This is handled automatically.

  • Handshake retransmission: Since UDP does not guarantee delivery, DTLS must handle retransmission of handshake messages internally. If a handshake flight is lost, DTLS retransmits it. This is transparent to the application.

DTLS vs QUIC

Both DTLS and QUIC run over UDP and both provide encryption, but they operate at different layers and solve different problems.

QUIC is a full transport protocol. It provides multiplexed streams, ordered and reliable delivery within each stream, built-in congestion control, flow control, and connection migration. It integrates TLS 1.3 directly into its handshake. When you use QUIC, you get a complete replacement for TCP+TLS.

DTLS is just a security layer. It encrypts and authenticates individual datagrams and that is all. There are no streams, no ordering guarantees, no reliability, no congestion control. The application gets back exactly what UDP gives it -- discrete messages that may arrive out of order or not at all -- but encrypted. Everything above the security layer is the application's responsibility.

The choice between them is straightforward. If you need reliable, ordered, multiplexed communication over UDP, use QUIC. If you need to encrypt datagrams and nothing more -- because your application already handles ordering, loss recovery, or simply does not need them -- use DTLS.

In practice, QUIC replaces TCP+TLS for connection-oriented workloads (HTTP/3, general-purpose RPC). DTLS is for protocols that are inherently datagram-oriented: real-time media (WebRTC/SRTP), IoT messaging (CoAP), game state updates, DNS over DTLS.

It's good to have them both.

Building and running

The implementation is not yet available in any release. To try it, you need to build Node.js from the PR branch with the --experimental-dtls configure flag, and then run with the --experimental-dtls runtime flag:

git clone https://github.com/nodejs/node
cd node
git fetch origin pull/63182/head:dtls
git checkout dtls
./configure --ninja --experimental-dtls
make -j16
./node --experimental-dtls my-app.mjs

The module is accessible via the node: scheme:

import { listen, connect } from 'node:dtls';

Because DTLS mandates TLS, you will need certificates. For testing, a self-signed certificate works:

openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
  -keyout key.pem -out cert.pem -days 365 -nodes \
  -subj '/CN=localhost'

The API at a glance

The node:dtls module exports four things: two convenience functions (listen and connect) and two classes (DTLSEndpoint and DTLSSession).

  • A DTLSEndpoint manages a UDP socket and multiplexes DTLS sessions over it. It can act as a server (accepting inbound connections via listen) or a client (initiating outbound connections via connect). A single endpoint can handle many concurrent sessions.

  • A DTLSSession represents one DTLS association with a remote peer. It has callback properties for receiving data (onmessage), handling errors (onerror), and observing handshake completion (onhandshake). The session.opened promise resolves when the handshake completes; the session.closed promise resolves when the session is torn down.

The data flow is straightforward: the endpoint receives UDP packets, dispatches them to the appropriate session, and the session decrypts the DTLS records and delivers the plaintext to the onmessage callback. In the outbound direction, session.send() encrypts the data and sends it out through the endpoint's UDP socket.

A simple echo server

Here is a minimal DTLS server that accepts a connection, receives a message, and echoes it back:

echo-server.mjs
import { listen } from 'node:dtls';
import { readFileSync } from 'node:fs';
 
const endpoint = listen((session) => {
  session.onmessage = (data) => {
    console.log('Received:', data.toString());
    session.send(data);  // Echo it back.
  };
 
  session.onhandshake = (protocol) => {
    console.log('Handshake complete:', protocol);
  };
}, {
  cert: readFileSync('cert.pem', 'utf8'),
  key: readFileSync('key.pem', 'utf8'),
  port: 4433,
  host: '127.0.0.1',
});
 
console.log('DTLS server listening on', endpoint.address);

A few things to note:

  • The listen() function takes a callback that is invoked once for each new DTLS session. This is conceptually similar to the 'connection' event on a TCP server.

  • The onmessage callback receives a Buffer with the decrypted application data. Each callback invocation corresponds to one datagram.

  • The onhandshake callback fires when the DTLS handshake completes. The protocol argument is the negotiated DTLS version (e.g. 'DTLSv1.2').

  • The server automatically handles cookie exchange for DoS protection. No configuration is needed.

A simple echo client

The client side:

echo-client.mjs
import { connect } from 'node:dtls';
import { readFileSync } from 'node:fs';
 
const session = connect('127.0.0.1', 4433, {
  ca: [readFileSync('cert.pem', 'utf8')],
});
 
session.onmessage = (data) => {
  console.log('Echo:', data.toString());
};
 
// Wait for the handshake to complete.
const { protocol } = await session.opened;
console.log('Connected:', protocol);
 
// Send a message.
session.send('Hello from the DTLS client!');

The connect() function returns a DTLSSession immediately. The session begins the DTLS handshake in the background. The session.opened promise resolves once the handshake completes, after which you can send and receive data.

Clean shutdown with Symbol.asyncDispose

Both DTLSEndpoint and DTLSSession implement Symbol.asyncDispose, so they work with the await using syntax for automatic cleanup:

dispose.mjs
import { listen, connect } from 'node:dtls';
import { readFileSync } from 'node:fs';
 
const cert = readFileSync('cert.pem', 'utf8');
const key = readFileSync('key.pem', 'utf8');
 
{
  await using endpoint = listen((session) => {
    session.onmessage = (data) => session.send(data);
  }, { cert, key, port: 0, host: '127.0.0.1' });
 
  await using session = connect('127.0.0.1', endpoint.address.port, {
    ca: [cert],
  });
 
  await session.opened;
  session.send('ping');
 
  // When the block exits, session.close() and endpoint.close()
  // are called automatically via Symbol.asyncDispose.
}

Graceful close vs. immediate destroy

The same two-tier teardown pattern used in node:quic applies here:

// Graceful close: sends a close_notify alert and waits for
// the session to shut down cleanly. Returns a promise.
await session.close();
 
// Immediate destroy: tears down the session immediately
// without sending close_notify.
session.destroy();
 
// Destroy with an error: the session.closed promise rejects.
session.destroy(new Error('something went wrong'));

The same pattern applies to endpoints. endpoint.close() gracefully closes all active sessions before releasing the UDP socket. endpoint.destroy() tears everything down immediately.

ALPN negotiation

DTLS supports Application-Layer Protocol Negotiation, just like TLS. This is useful when the same server needs to handle different application protocols on the same port:

// Server: offer ALPN protocols.
const endpoint = listen((session) => {
  session.onhandshake = () => {
    console.log('Negotiated:', session.alpnProtocol);
  };
}, {
  cert, key, port: 4433,
  alpn: ['my-protocol-v2', 'my-protocol-v1'],
});
 
// Client: request a specific protocol.
const session = connect('127.0.0.1', 4433, {
  ca: [cert],
  alpn: ['my-protocol-v2'],
});

DTLS-SRTP

One of the most common use cases for DTLS is WebRTC, where the DTLS handshake negotiates the SRTP protection profile and provides the keying material for encrypting media streams. The node:dtls module supports this directly:

dtls-srtp.mjs
import { listen, connect } from 'node:dtls';
import { readFileSync } from 'node:fs';
 
const cert = readFileSync('cert.pem', 'utf8');
const key = readFileSync('key.pem', 'utf8');
 
// Server with SRTP profile negotiation.
const endpoint = listen((session) => {
  session.onhandshake = () => {
    console.log('SRTP profile:', session.srtpProfile);
 
    // Export keying material for SRTP (RFC 5705).
    const keys = session.exportKeyingMaterial(
      60,
      'EXTRACTOR-dtls_srtp',
    );
    console.log('SRTP keying material:', keys);
  };
}, {
  cert, key, port: 5004,
  srtp: 'SRTP_AES128_CM_SHA1_80:SRTP_AEAD_AES_128_GCM',
});
 
// Client with SRTP.
const session = connect('127.0.0.1', 5004, {
  ca: [cert],
  srtp: 'SRTP_AEAD_AES_128_GCM:SRTP_AES128_CM_SHA1_80',
});
 
await session.opened;
console.log('Negotiated SRTP:', session.srtpProfile);
const keys = session.exportKeyingMaterial(60, 'EXTRACTOR-dtls_srtp');

The exportKeyingMaterial() method implements RFC 5705, which is how DTLS-SRTP derives the symmetric keys used to encrypt and authenticate RTP/RTCP packets.

Endpoint statistics

Both endpoints and sessions track live statistics that are useful for monitoring and debugging:

const endpoint = listen(callback, options);
 
// Endpoint stats.
console.log({
  bytesReceived: endpoint.stats.bytesReceived,
  bytesSent: endpoint.stats.bytesSent,
  packetsReceived: endpoint.stats.packetsReceived,
  packetsSent: endpoint.stats.packetsSent,
  serverSessions: endpoint.stats.serverSessions,
  clientSessions: endpoint.stats.clientSessions,
});
 
// Session stats (after handshake).
const session = connect(host, port, options);
await session.opened;
 
console.log({
  bytesReceived: session.stats.bytesReceived,
  bytesSent: session.stats.bytesSent,
  messagesReceived: session.stats.messagesReceived,
  messagesSent: session.stats.messagesSent,
  retransmitCount: session.stats.retransmitCount,
});

All stat values are BigInts. The stats objects are live views backed by the C++ internals -- they update as data flows through the endpoint and sessions.

MTU considerations

Since libuv does not currently support Path MTU Discovery for UDP, the DTLS module uses a conservative default MTU of 1200 bytes. This value works across virtually all network paths but may be suboptimal for local networks. The MTU is configurable:

// For a local network where the path MTU is known.
const endpoint = listen(callback, {
  cert, key, port: 4433,
  mtu: 1400,
});

The minimum allowed MTU is 256 bytes and the maximum is 65535. If libuv gains PMTUD support through the proposed extensions, the DTLS module would be able to probe for the path MTU automatically.

Session properties

After the handshake completes, the session exposes several read-only properties about the negotiated connection:

await session.opened;
 
console.log(session.protocol);       // 'DTLSv1.2'
console.log(session.cipher);         // { name, standardName, version }
console.log(session.remoteAddress);  // { address, family, port }
console.log(session.alpnProtocol);   // negotiated ALPN, if any
console.log(session.srtpProfile);    // negotiated SRTP profile, if any
console.log(session.peerCertificate); // peer cert in PEM format

These properties return undefined after the session has been destroyed.

The experimental caveat

I want to be clear about the status of this implementation. It is experimental in every sense:

  • The API will change. This is a first pass. Option names, callback conventions, and class structure may all evolve based on feedback.

  • It requires opt-in at build time and runtime. You must configure Node.js with --experimental-dtls and pass the --experimental-dtls flag when running. This will remain the case until the implementation matures.

  • It is not production-ready. The focus has been on getting the API right and the basic functionality working. Security hardening, performance optimization, and edge case handling are still ahead.

  • DTLS 1.3 is not yet supported. The current implementation uses DTLS 1.2 via OpenSSL. DTLS 1.3 support depends on OpenSSL's progress on that front.

If you try it out and run into issues or have feedback on the API design, please comment on the pull request or file an issue on the Node.js repository. Feedback at this stage is invaluable.