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
DTLSEndpointmanages this multiplexing, routing incoming packets to the correctDTLSSessionbased 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.mjsThe 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
DTLSEndpointmanages a UDP socket and multiplexes DTLS sessions over it. It can act as a server (accepting inbound connections vialisten) or a client (initiating outbound connections viaconnect). A single endpoint can handle many concurrent sessions. -
A
DTLSSessionrepresents one DTLS association with a remote peer. It has callback properties for receiving data (onmessage), handling errors (onerror), and observing handshake completion (onhandshake). Thesession.openedpromise resolves when the handshake completes; thesession.closedpromise 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:
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
onmessagecallback receives aBufferwith the decrypted application data. Each callback invocation corresponds to one datagram. -
The
onhandshakecallback fires when the DTLS handshake completes. Theprotocolargument 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:
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:
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:
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 formatThese 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-dtlsand pass the--experimental-dtlsflag 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.