diff --git a/tools/testing/selftests/net/ovpn/Makefile b/tools/testing/selftests/net/ovpn/Makefile
index 169f0464ac3a..515aeac945a4 100644
--- a/tools/testing/selftests/net/ovpn/Makefile
+++ b/tools/testing/selftests/net/ovpn/Makefile
@@ -36,6 +36,8 @@ TEST_PROGS := \
 	test-chachapoly.sh \
 	test-close-socket-tcp.sh \
 	test-close-socket.sh \
+	test-epoch-tcp.sh \
+	test-epoch.sh \
 	test-float.sh \
 	test-large-mtu.sh \
 	test-mark.sh \
diff --git a/tools/testing/selftests/net/ovpn/common.sh b/tools/testing/selftests/net/ovpn/common.sh
index 2d844eb3aa6e..de5ff9a9e97a 100644
--- a/tools/testing/selftests/net/ovpn/common.sh
+++ b/tools/testing/selftests/net/ovpn/common.sh
@@ -12,6 +12,7 @@ OVPN_TCP_PEERS_FILE=${OVPN_TCP_PEERS_FILE:-tcp_peers.txt}
 OVPN_CLI=${OVPN_CLI:-${OVPN_COMMON_DIR}/ovpn-cli}
 OVPN_YNL=${OVPN_YNL:-${OVPN_COMMON_DIR}/../../../../net/ynl/pyynl/cli.py}
 OVPN_ALG=${OVPN_ALG:-aes}
+OVPN_KEY_TYPE=${OVPN_KEY_TYPE:-direct}
 OVPN_PROTO=${OVPN_PROTO:-UDP}
 OVPN_FLOAT=${OVPN_FLOAT:-0}
 OVPN_SYMMETRIC_ID=${OVPN_SYMMETRIC_ID:-0}
@@ -184,14 +185,26 @@ ovpn_build_capture_filter() {
 		# address. The IPv6 branch assumes there are no extension
 		# headers in the outer packet.
 		if [[ "${2}" == *:* ]]; then
-			printf "ip6 and ip6[6] = 17 and ip6[48:4] = %s" "${1}"
+			printf "ip6 and ip6[6] = 17 and ip6[48:4] = %s" \
+				"${1}"
+			if [ "${OVPN_KEY_TYPE}" == "epoch" ]; then
+				printf " and ip6[52:2] = 0x0001"
+			fi
 		else
 			printf "ip and udp[8:4] = %s" "${1}"
+			if [ "${OVPN_KEY_TYPE}" == "epoch" ]; then
+				printf " and udp[12:2] = 0x0001"
+			fi
 		fi
 	else
 		# openvpn over TCP prepends a 2-byte packet length ahead of the
 		# DATA_V2 opcode, so skip it before matching the payload header
-		printf "ip and tcp[(((tcp[12] & 0xf0) >> 2) + 2):4] = %s" "${1}"
+		printf "ip and tcp[(((tcp[12] & 0xf0) >> 2) + 2):4] = %s" \
+			"${1}"
+		if [ "${OVPN_KEY_TYPE}" == "epoch" ]; then
+			printf " and tcp[(((tcp[12] & 0xf0) >> 2) + 6):2] = "
+			printf "0x0001"
+		fi
 	fi
 }
 
@@ -222,8 +235,8 @@ ovpn_add_peer() {
 
 			for p in $(seq 1 ${OVPN_NUM_PEERS}); do
 				ip netns exec "${server_ns}" ${OVPN_CLI} \
-					new_key tun0 ${p} 1 0 ${OVPN_ALG} 0 \
-					data64.key
+					new_key tun0 ${p} 1 0 ${OVPN_ALG} \
+					${OVPN_KEY_TYPE} 0 data64.key
 			done
 		else
 			peer_ns="ovpn_peer${1}"
@@ -245,7 +258,8 @@ ovpn_add_peer() {
 				tun${1} ${PEER_ID} ${TX_ID} ${LPORT} ${RADDR} \
 				${RPORT}
 			ip netns exec "${peer_ns}" ${OVPN_CLI} new_key tun${1} \
-				${PEER_ID} 1 0 ${OVPN_ALG} 1 data64.key
+				${PEER_ID} 1 0 ${OVPN_ALG} ${OVPN_KEY_TYPE} \
+				1 data64.key
 		fi
 	else
 		if [ ${1} -eq 0 ]; then
@@ -254,7 +268,8 @@ ovpn_add_peer() {
 				for p in $(seq 1 ${OVPN_NUM_PEERS}); do
 					ip netns exec "${server_ns}" \
 						${OVPN_CLI} new_key tun0 ${p} \
-						1 0 ${OVPN_ALG} 0 data64.key
+						1 0 ${OVPN_ALG} \
+						${OVPN_KEY_TYPE} 0 data64.key
 				done
 			}) &
 			sleep 5
@@ -269,7 +284,8 @@ ovpn_add_peer() {
 				TX_ID=${1}
 			fi
 			ip netns exec "${peer_ns}" ${OVPN_CLI} connect tun${1} \
-				${PEER_ID} ${TX_ID} 10.10.${1}.1 1 data64.key
+				${PEER_ID} ${TX_ID} 10.10.${1}.1 1 \
+				${OVPN_KEY_TYPE} data64.key
 		fi
 	fi
 }
diff --git a/tools/testing/selftests/net/ovpn/ovpn-cli.c b/tools/testing/selftests/net/ovpn/ovpn-cli.c
index d40953375c86..90bfcb6d39e2 100644
--- a/tools/testing/selftests/net/ovpn/ovpn-cli.c
+++ b/tools/testing/selftests/net/ovpn/ovpn-cli.c
@@ -61,6 +61,11 @@ enum ovpn_key_direction {
 	KEY_DIR_OUT,
 };
 
+enum ovpn_key_type {
+	KEY_TYPE_DIRECT = 0,
+	KEY_TYPE_EPOCH,
+};
+
 #define KEY_LEN (256 / 8)
 #define NONCE_LEN 8
 
@@ -130,6 +135,7 @@ struct ovpn_ctx {
 	__u32 keepalive_interval;
 	__u32 keepalive_timeout;
 
+	enum ovpn_key_type key_type;
 	enum ovpn_key_direction key_dir;
 	enum ovpn_key_slot key_slot;
 	int key_id;
@@ -451,6 +457,18 @@ static int ovpn_parse_cipher(const char *cipher, struct ovpn_ctx *ctx)
 	return 0;
 }
 
+static int ovpn_parse_key_type(const char *type, struct ovpn_ctx *ctx)
+{
+	if (strcmp(type, "direct") == 0)
+		ctx->key_type = KEY_TYPE_DIRECT;
+	else if (strcmp(type, "epoch") == 0)
+		ctx->key_type = KEY_TYPE_EPOCH;
+	else
+		return -ENOTSUP;
+
+	return 0;
+}
+
 static int ovpn_parse_key_direction(const char *dir, struct ovpn_ctx *ctx)
 {
 	int in_dir;
@@ -921,9 +939,38 @@ static int ovpn_get_peer(struct ovpn_ctx *ovpn)
 	return ret;
 }
 
+static int ovpn_put_direct_key(struct nl_msg *msg, int attr, const __u8 *key,
+			       const __u8 *nonce)
+{
+	struct nlattr *key_dir;
+
+	key_dir = nla_nest_start(msg, attr);
+	NLA_PUT(msg, OVPN_A_KEYDIR_CIPHER_KEY, KEY_LEN, key);
+	NLA_PUT(msg, OVPN_A_KEYDIR_NONCE_TAIL, NONCE_LEN, nonce);
+	nla_nest_end(msg, key_dir);
+
+	return 0;
+nla_put_failure:
+	return -1;
+}
+
+static int ovpn_put_epoch_key(struct nl_msg *msg, int attr, const __u8 *key)
+{
+	struct nlattr *epoch;
+
+	epoch = nla_nest_start(msg, attr);
+	NLA_PUT(msg, OVPN_A_EPOCH_KEY, KEY_LEN, key);
+	NLA_PUT_U32(msg, OVPN_A_EPOCH_CIPHER_KEY_LEN, KEY_LEN);
+	nla_nest_end(msg, epoch);
+
+	return 0;
+nla_put_failure:
+	return -1;
+}
+
 static int ovpn_new_key(struct ovpn_ctx *ovpn)
 {
-	struct nlattr *keyconf, *key_dir;
+	struct nlattr *keyconf;
 	struct nl_ctx *ctx;
 	int ret = -1;
 
@@ -937,15 +984,37 @@ static int ovpn_new_key(struct ovpn_ctx *ovpn)
 	NLA_PUT_U32(ctx->nl_msg, OVPN_A_KEYCONF_KEY_ID, ovpn->key_id);
 	NLA_PUT_U32(ctx->nl_msg, OVPN_A_KEYCONF_CIPHER_ALG, ovpn->cipher);
 
-	key_dir = nla_nest_start(ctx->nl_msg, OVPN_A_KEYCONF_ENCRYPT_DIR);
-	NLA_PUT(ctx->nl_msg, OVPN_A_KEYDIR_CIPHER_KEY, KEY_LEN, ovpn->key_enc);
-	NLA_PUT(ctx->nl_msg, OVPN_A_KEYDIR_NONCE_TAIL, NONCE_LEN, ovpn->nonce);
-	nla_nest_end(ctx->nl_msg, key_dir);
+	switch (ovpn->key_type) {
+	case KEY_TYPE_DIRECT:
+		ret = ovpn_put_direct_key(ctx->nl_msg,
+					  OVPN_A_KEYCONF_ENCRYPT_DIR,
+					  ovpn->key_enc, ovpn->nonce);
+		if (ret)
+			goto nla_put_failure;
 
-	key_dir = nla_nest_start(ctx->nl_msg, OVPN_A_KEYCONF_DECRYPT_DIR);
-	NLA_PUT(ctx->nl_msg, OVPN_A_KEYDIR_CIPHER_KEY, KEY_LEN, ovpn->key_dec);
-	NLA_PUT(ctx->nl_msg, OVPN_A_KEYDIR_NONCE_TAIL, NONCE_LEN, ovpn->nonce);
-	nla_nest_end(ctx->nl_msg, key_dir);
+		ret = ovpn_put_direct_key(ctx->nl_msg,
+					  OVPN_A_KEYCONF_DECRYPT_DIR,
+					  ovpn->key_dec, ovpn->nonce);
+		if (ret)
+			goto nla_put_failure;
+		break;
+	case KEY_TYPE_EPOCH:
+		ret = ovpn_put_epoch_key(ctx->nl_msg,
+					 OVPN_A_KEYCONF_ENCRYPT_EPOCH,
+					 ovpn->key_enc);
+		if (ret)
+			goto nla_put_failure;
+
+		ret = ovpn_put_epoch_key(ctx->nl_msg,
+					 OVPN_A_KEYCONF_DECRYPT_EPOCH,
+					 ovpn->key_dec);
+		if (ret)
+			goto nla_put_failure;
+		break;
+	default:
+		ret = -EINVAL;
+		goto nla_put_failure;
+	}
 
 	nla_nest_end(ctx->nl_msg, keyconf);
 
@@ -1691,7 +1760,7 @@ static void usage(const char *cmd)
 		"\tipv6: whether the socket should listen to the IPv6 wildcard address\n");
 
 	fprintf(stderr,
-		"* connect <iface> <peer_id> <tx_id> <raddr> <rport> [key_file]: start connecting peer of TCP-based VPN session\n");
+		"* connect <iface> <peer_id> <tx_id> <raddr> <rport> [key_type key_file]: start connecting peer of TCP-based VPN session\n");
 	fprintf(stderr, "\tiface: ovpn interface name\n");
 	fprintf(stderr,
 		"\tpeer_id: peer ID found in data packets received from this peer\n");
@@ -1699,8 +1768,8 @@ static void usage(const char *cmd)
 		"\ttx_id: peer ID to be used when sending to this peer, 'none' for symmetric peer ID\n");
 	fprintf(stderr, "\traddr: peer IP address to connect to\n");
 	fprintf(stderr, "\trport: peer TCP port to connect to\n");
-	fprintf(stderr,
-		"\tkey_file: file containing the symmetric key for encryption\n");
+	fprintf(stderr, "\tkey_type: key type, supported: direct, epoch\n");
+	fprintf(stderr, "\tkey_file: file containing the pre-shared key\n");
 
 	fprintf(stderr,
 		"* new_peer <iface> <peer_id> <tx_id> <lport> <raddr> <rport> [vpnaddr]: add new peer\n");
@@ -1748,7 +1817,7 @@ static void usage(const char *cmd)
 		"\tpeer_id: peer ID of the peer to query. All peers are returned if omitted\n");
 
 	fprintf(stderr,
-		"* new_key <iface> <peer_id> <slot> <key_id> <cipher> <key_dir> <key_file>: set data channel key\n");
+		"* new_key <iface> <peer_id> <slot> <key_id> <cipher> <key_type> <key_dir> <key_file>: set data channel key\n");
 	fprintf(stderr, "\tiface: ovpn interface name\n");
 	fprintf(stderr,
 		"\tpeer_id: peer ID of the peer to configure the key for\n");
@@ -1756,6 +1825,7 @@ static void usage(const char *cmd)
 	fprintf(stderr, "\tkey_id: an ID from 0 to 7\n");
 	fprintf(stderr,
 		"\tcipher: cipher to use, supported: aes (AES-GCM), chachapoly (CHACHA20POLY1305)\n");
+	fprintf(stderr, "\tkey_type: key type, supported: direct, epoch\n");
 	fprintf(stderr,
 		"\tkey_dir: key direction, must 0 on one host and 1 on the other\n");
 	fprintf(stderr, "\tkey_file: file containing the pre-shared key\n");
@@ -2247,12 +2317,19 @@ static int ovpn_parse_cmd_args(struct ovpn_ctx *ovpn, int argc, char *argv[])
 		}
 
 		if (argc > 7) {
+			if (argc < 9)
+				return -EINVAL;
+
 			ovpn->key_slot = OVPN_KEY_SLOT_PRIMARY;
 			ovpn->key_id = 0;
 			ovpn->cipher = OVPN_CIPHER_ALG_AES_GCM;
 			ovpn->key_dir = KEY_DIR_OUT;
 
-			ret = ovpn_parse_key(argv[7], ovpn);
+			ret = ovpn_parse_key_type(argv[7], ovpn);
+			if (ret < 0)
+				return -1;
+
+			ret = ovpn_parse_key(argv[8], ovpn);
 			if (ret)
 				return -1;
 		}
@@ -2351,7 +2428,7 @@ static int ovpn_parse_cmd_args(struct ovpn_ctx *ovpn, int argc, char *argv[])
 		}
 		break;
 	case CMD_NEW_KEY:
-		if (argc < 9)
+		if (argc < 10)
 			return -EINVAL;
 
 		ovpn->peer_id = strtoul(argv[3], NULL, 10);
@@ -2374,11 +2451,15 @@ static int ovpn_parse_cmd_args(struct ovpn_ctx *ovpn, int argc, char *argv[])
 		if (ret < 0)
 			return -1;
 
-		ret = ovpn_parse_key_direction(argv[7], ovpn);
+		ret = ovpn_parse_key_type(argv[7], ovpn);
+		if (ret < 0)
+			return -1;
+
+		ret = ovpn_parse_key_direction(argv[8], ovpn);
 		if (ret < 0)
 			return -1;
 
-		ret = ovpn_parse_key(argv[8], ovpn);
+		ret = ovpn_parse_key(argv[9], ovpn);
 		if (ret)
 			return -1;
 		break;
@@ -2442,6 +2523,7 @@ int main(int argc, char *argv[])
 	memset(&ovpn, 0, sizeof(ovpn));
 	ovpn.sa_family = AF_UNSPEC;
 	ovpn.cipher = OVPN_CIPHER_ALG_NONE;
+	ovpn.key_type = KEY_TYPE_DIRECT;
 
 	ovpn.cmd = ovpn_parse_cmd(argv[1]);
 	if (ovpn.cmd == CMD_INVALID) {
diff --git a/tools/testing/selftests/net/ovpn/test-epoch-tcp.sh b/tools/testing/selftests/net/ovpn/test-epoch-tcp.sh
new file mode 100755
index 000000000000..ec7b243d1923
--- /dev/null
+++ b/tools/testing/selftests/net/ovpn/test-epoch-tcp.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+# SPDX-License-Identifier: GPL-2.0
+# Copyright (C) 2026 OpenVPN, Inc.
+#
+#	Author:	Ralf Lici <ralf@mandelbit.com>
+#		Antonio Quartulli <antonio@openvpn.net>
+
+OVPN_KEY_TYPE="epoch"
+OVPN_PROTO="TCP"
+
+source test.sh
diff --git a/tools/testing/selftests/net/ovpn/test-epoch.sh b/tools/testing/selftests/net/ovpn/test-epoch.sh
new file mode 100755
index 000000000000..17641dad87c4
--- /dev/null
+++ b/tools/testing/selftests/net/ovpn/test-epoch.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+# SPDX-License-Identifier: GPL-2.0
+# Copyright (C) 2026 OpenVPN, Inc.
+#
+#	Author:	Ralf Lici <ralf@mandelbit.com>
+#		Antonio Quartulli <antonio@openvpn.net>
+
+OVPN_KEY_TYPE="epoch"
+
+source test.sh
diff --git a/tools/testing/selftests/net/ovpn/test-mark.sh b/tools/testing/selftests/net/ovpn/test-mark.sh
index 7c1d56e9c525..43b4798fefde 100755
--- a/tools/testing/selftests/net/ovpn/test-mark.sh
+++ b/tools/testing/selftests/net/ovpn/test-mark.sh
@@ -43,7 +43,8 @@ ovpn_mark_prepare_network() {
 	for p in $(seq 1 3); do
 		ovpn_cmd_ok "install server key for peer ${p}" \
 			ip netns exec ovpn_peer0 "${OVPN_CLI}" new_key tun0 \
-				"${p}" 1 0 "${OVPN_ALG}" 0 data64.key
+				"${p}" 1 0 "${OVPN_ALG}" "${OVPN_KEY_TYPE}" \
+				0 data64.key
 	done
 
 	for p in $(seq 1 3); do
diff --git a/tools/testing/selftests/net/ovpn/test.sh b/tools/testing/selftests/net/ovpn/test.sh
index 9b5610837032..a30842903fdb 100755
--- a/tools/testing/selftests/net/ovpn/test.sh
+++ b/tools/testing/selftests/net/ovpn/test.sh
@@ -67,14 +67,15 @@ ovpn_run_basic_traffic() {
 	local tcpdump_timeout="1.5s"
 
 	for p in $(seq 1 ${OVPN_NUM_PEERS}); do
-		# The first part of the data packet header consists of:
+		# The tcpdump filters match the cleartext data packet prefix:
 		# - TCP only: 2 bytes for the packet length
 		# - 5 bits for opcode ("9" for DATA_V2)
 		# - 3 bits for key-id ("0" at this point)
-		# - 12 bytes for peer-id:
+		# - 24 bits for peer-id:
 		#     - with asymmetric ID: "${p}" one way and "${p} + 9" the
 		#	other way
 		#     - with symmetric ID: "${p}" both ways
+		# - epoch keys only: 2 bytes for epoch ("1" at this point)
 		header1=$(printf "0x4800000%x" ${p})
 		header2=$(printf "0x4800000%x" $((p + OVPN_ID_OFFSET)))
 		raddr=""
@@ -152,11 +153,12 @@ ovpn_run_key_rollover() {
 		peer_ns="ovpn_peer${p}"
 		ovpn_cmd_ok "add secondary key on peer0 for peer ${p}" \
 			ip netns exec ovpn_peer0 ${OVPN_CLI} new_key tun0 \
-				${p} 2 1 ${OVPN_ALG} 0 data64.key
+				${p} 2 1 ${OVPN_ALG} ${OVPN_KEY_TYPE} \
+				0 data64.key
 		ovpn_cmd_ok "add secondary key on peer${p} for peer ${p}" \
 			ip netns exec "${peer_ns}" ${OVPN_CLI} new_key tun${p} \
-				$((p + OVPN_ID_OFFSET)) 2 1 ${OVPN_ALG} 1 \
-				data64.key
+				$((p + OVPN_ID_OFFSET)) 2 1 ${OVPN_ALG} \
+				${OVPN_KEY_TYPE} 1 data64.key
 		ovpn_cmd_ok "swap keys on peer${p}" \
 			ip netns exec "${peer_ns}" ${OVPN_CLI} swap_keys \
 				tun${p} $((p + OVPN_ID_OFFSET))
