You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficserver.apache.org by ma...@apache.org on 2026/04/08 15:40:29 UTC

(trafficserver) branch master updated: Add jax_fingerprint plugin (#12995)

This is an automated email from the ASF dual-hosted git repository.

maskit pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git


The following commit(s) were added to refs/heads/master by this push:
     new 910f3e53e9 Add jax_fingerprint plugin (#12995)
910f3e53e9 is described below

commit 910f3e53e9e1ad1572d23e95532aae946e7c1e1f
Author: Masakazu Kitajo <ma...@apache.org>
AuthorDate: Wed Apr 8 09:40:22 2026 -0600

    Add jax_fingerprint plugin (#12995)
    
    This adds an experimental plugin jax_fingerprint. The basic functionality is the same as ja3_fingerprint and ja4_fingerprint, but the configuration is more flexible.
---
 cmake/ExperimentalPlugins.cmake                    |   1 +
 doc/admin-guide/plugins/index.en.rst               |   4 +
 doc/admin-guide/plugins/jax_fingerprint.en.rst     | 182 ++++++++
 plugins/experimental/CMakeLists.txt                |   3 +
 .../experimental/jax_fingerprint/CMakeLists.txt    |  54 +++
 plugins/experimental/jax_fingerprint/config.h      |  70 +++
 plugins/experimental/jax_fingerprint/context.cc    |  78 ++++
 plugins/experimental/jax_fingerprint/context.h     |  48 +++
 plugins/experimental/jax_fingerprint/header.cc     | 162 +++++++
 plugins/experimental/jax_fingerprint/header.h      |  34 ++
 .../experimental/jax_fingerprint/ja3/ja3_method.cc | 128 ++++++
 .../experimental/jax_fingerprint/ja3/ja3_method.h  |  32 ++
 .../experimental/jax_fingerprint/ja3/ja3_utils.cc  | 108 +++++
 .../experimental/jax_fingerprint/ja3/ja3_utils.h   |  73 ++++
 .../experimental/jax_fingerprint/ja3/test_ja3.cc   |  96 +++++
 plugins/experimental/jax_fingerprint/ja4/ja4.cc    | 175 ++++++++
 plugins/experimental/jax_fingerprint/ja4/ja4.h     | 171 ++++++++
 .../experimental/jax_fingerprint/ja4/ja4_method.cc | 159 +++++++
 .../experimental/jax_fingerprint/ja4/ja4_method.h  |  32 ++
 .../experimental/jax_fingerprint/ja4/test_ja4.cc   | 425 ++++++++++++++++++
 .../ja4/tls_client_hello_summary.cc                | 112 +++++
 .../jax_fingerprint/ja4h/datasource.cc             |  44 ++
 .../experimental/jax_fingerprint/ja4h/datasource.h |  42 ++
 plugins/experimental/jax_fingerprint/ja4h/ja4h.cc  | 118 +++++
 plugins/experimental/jax_fingerprint/ja4h/ja4h.h   |  41 ++
 .../jax_fingerprint/ja4h/ja4h_method.cc            | 173 ++++++++
 .../jax_fingerprint/ja4h/ja4h_method.h             |  32 ++
 .../experimental/jax_fingerprint/ja4h/test_ja4h.cc | 168 ++++++++
 plugins/experimental/jax_fingerprint/log.cc        |  52 +++
 plugins/experimental/jax_fingerprint/log.h         |  30 ++
 plugins/experimental/jax_fingerprint/method.h      |  68 +++
 plugins/experimental/jax_fingerprint/plugin.cc     | 480 +++++++++++++++++++++
 plugins/experimental/jax_fingerprint/plugin.h      |  31 ++
 plugins/experimental/jax_fingerprint/userarg.cc    |  53 +++
 plugins/experimental/jax_fingerprint/userarg.h     |  32 ++
 .../jax_fingerprint/jax_fingerprint.test.py        | 339 +++++++++++++++
 .../jax_fingerprint_append.replay.yaml             | 114 +++++
 .../jax_fingerprint_global.replay.yaml             | 111 +++++
 .../jax_fingerprint_global_h2.replay.yaml          | 121 ++++++
 .../jax_fingerprint_hybrid_servernames.replay.yaml | 124 ++++++
 .../jax_fingerprint_keep.replay.yaml               | 111 +++++
 .../jax_fingerprint_remap.replay.yaml              | 109 +++++
 .../jax_fingerprint_remap_tls.replay.yaml          | 120 ++++++
 .../jax_fingerprint_servernames.replay.yaml        | 114 +++++
 .../jax_fingerprint_tls.replay.yaml                | 111 +++++
 45 files changed, 4885 insertions(+)

diff --git a/cmake/ExperimentalPlugins.cmake b/cmake/ExperimentalPlugins.cmake
index 764571546c..5e73ffa5ec 100644
--- a/cmake/ExperimentalPlugins.cmake
+++ b/cmake/ExperimentalPlugins.cmake
@@ -43,6 +43,7 @@ auto_option(HTTP_STATS FEATURE_VAR BUILD_HTTP_STATS DEFAULT ${_DEFAULT})
 auto_option(ICAP FEATURE_VAR BUILD_ICAP DEFAULT ${_DEFAULT})
 auto_option(INLINER FEATURE_VAR BUILD_INLINER DEFAULT ${_DEFAULT})
 auto_option(JA4_FINGERPRINT FEATURE_VAR BUILD_JA4_FINGERPRINT VAR_DEPENDS DEFAULT ${_DEFAULT})
+auto_option(JAX_FINGERPRINT FEATURE_VAR BUILD_JAX_FINGERPRINT VAR_DEPENDS DEFAULT ${_DEFAULT})
 auto_option(
   MAGICK
   FEATURE_VAR
diff --git a/doc/admin-guide/plugins/index.en.rst b/doc/admin-guide/plugins/index.en.rst
index 5379c9a72e..c2847b3237 100644
--- a/doc/admin-guide/plugins/index.en.rst
+++ b/doc/admin-guide/plugins/index.en.rst
@@ -179,6 +179,7 @@ directory of the |TS| source tree. Experimental plugins can be compiled by passi
    Hook Trace <hook-trace.en>
    ICAP <icap.en>
    JA4 Fingerprint <ja4_fingerprint.en>
+   JAx Fingerprint <jax_fingerprint.en>
    Maxmind ACL <maxmind_acl.en>
    Memcache <memcache.en>
    Memory Profile <memory_profile.en>
@@ -236,6 +237,9 @@ directory of the |TS| source tree. Experimental plugins can be compiled by passi
 :doc:`JA4 Fingerprint <ja4_fingerprint.en>`
    Calculates JA4 Fingerprints for incoming TLS traffic.
 
+:doc:`JAx Fingerprint <jax_fingerprint.en>`
+   Calculates JAx Fingerprints.
+
 :doc:`MaxMind ACL <maxmind_acl.en>`
    ACL based on the maxmind geo databases (GeoIP2 mmdb and libmaxminddb)
 
diff --git a/doc/admin-guide/plugins/jax_fingerprint.en.rst b/doc/admin-guide/plugins/jax_fingerprint.en.rst
new file mode 100644
index 0000000000..e500cba6a2
--- /dev/null
+++ b/doc/admin-guide/plugins/jax_fingerprint.en.rst
@@ -0,0 +1,182 @@
+.. Licensed to the Apache Software Foundation (ASF) under one
+   or more contributor license agreements.  See the NOTICE file
+   distributed with this work for additional information
+   regarding copyright ownership.  The ASF licenses this file
+   to you under the Apache License, Version 2.0 (the
+   "License"); you may not use this file except in compliance
+   with the License.  You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing,
+   software distributed under the License is distributed on an
+   "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+   KIND, either express or implied.  See the License for the
+   specific language governing permissions and limitations
+   under the License.
+
+.. include:: ../../common.defs
+
+.. _admin-plugins-jax-fingerprint:
+
+JAx Fingerprint Plugin
+**********************
+
+Description
+===========
+
+The JAx Fingerprint plugin generates client fingerprints based on the JA4+ or JA3 algorithms designed by John Althouse.
+
+Fingerprints can be used for:
+
+* Client identification and tracking
+* Bot detection and mitigation
+* Security analytics and threat intelligence
+* Understanding client implementation patterns
+
+
+Plugin Configuration
+====================
+
+You can use the plugin as a global plugin, a remap plugin, or both.
+
+To use the plugin as a global plugin, add the following line to :file:`plugin.config`::
+
+    jax_fingerprint.so --standalone
+
+To use the plugin as a remap plugin, append the following line to a remap rule on :file:`remap.config`::
+
+    @plugin=jax_fingerprint.so @pparam=--standalone
+
+To use the plugin in a hybrid setup (both global and remap plugin), configure it in both :file:`plugin.config` and
+:file:`remap.config` without ``--standalone`` option.
+
+
+.. option:: --standalone
+
+This option enables you to use the plugin as either a global plugin, or a remap plugin. In other
+words, the option needs to be specified if you do not use the hybrid setup.
+
+.. option:: --method <JA4|JA4H|JA3>
+
+Fingerprinting method (e.g. JA4, JA3, etc.) to use. This option must be specified.
+
+.. option:: --mode <overwrite|keep|append>
+
+This option specifies what to do if requests from clients have the header names that are specified
+by `--header` and/or `--via-header`. Available setting values are "overwrite", "keep" and "append".
+The default mode is "overwrite".
+
+.. option:: --servernames <servername1,servername2,...>
+
+This option specifies server name(s) for which the plugin generates fingerprints.
+The value must be provided as a single comma separated value (no space) of server names.
+If the option is not specified, the plugin generates fingerprints for any server names.
+
+.. option:: --header <header_name>
+
+This option specifies the name of the header field where the plugin stores the generated fingerprint value. If not specified, header generation will be suppressed.
+
+.. option:: --via-header <via_header_name>
+
+This option specifies the name of the header field where the plugin stores the generated fingerprint-via value. If not specified, header generation will be suppressed.
+
+.. option:: --log-filename <filename>
+
+This option specifies the filename for the plugin log file. If not specified, log output will be suppressed.
+
+
+Plugin Behavior
+===============
+
+Global plugin setup
+-------------------
+
+Global plugin setup is the best if you:
+ * Need a fingerprint on every request
+
+Remap plugin setup
+------------------
+
+Remap plugin setup is the best if you:
+ * Need a fingerprint only on specific paths, or
+ * Cannot use Global plugin setup
+
+.. note:: For JA3 and JA4, fingerprints are always generated at the beginning of connections. Using remap plugin setup only reduces the overhead of adding HTTP headers and logging.
+
+Hybrid setup
+------------
+
+Hybrid setup is the best if you:
+ * Need a fingerprint only for specific server names (in TLS SNI extension), and
+ * Need a fingerprint only on specific paths
+
+
+Log Output
+==========
+
+The plugin outputs a log file in the Traffic Server log directory (typically ``/var/log/trafficserver/``) if a log filename is
+specified by ``--log-filename`` option.
+
+**Log Format**::
+
+    [timestamp] Client: <address>    <method_name>: <fingerprint>
+
+**Example**::
+
+    [Jan 29 10:15:23.456] Client: 192.168.1.100    JA4: t13d1516h2_8daaf6152771_b186095e22b6
+    [Jan 29 10:15:24.123] Client: 10.0.0.50        JA4: t13d1715h2_8daaf6152771_02713d6af862
+
+
+Using HTTP Headers in Origin Requests
+=====================================
+
+Origin servers can access the generated fingerprint through the injected HTTP header.
+This allows the origin to:
+
+* Make access control decisions based on client fingerprints
+* Log fingerprints for security analysis
+* Track client populations and TLS implementation patterns
+
+The fingerprint-via header allows origin servers to track which Traffic Server proxy handled the request when multiple proxies are deployed.
+
+
+Debugging
+=========
+
+To enable debug logging for the plugin, set the following in :file:`records.yaml`::
+
+    records:
+      diags:
+        debug:
+          enabled: 1
+          tags: jax_fingerprint
+
+
+Requirements
+============
+
+* Traffic Server must be built with TLS support (OpenSSL or BoringSSL) if you use JA3 or JA4
+
+
+See Also
+========
+* JA3 Technical Specification: https://github.com/FoxIO-LLC/ja3
+* JA4+ Technical Specification: https://github.com/FoxIO-LLC/ja4
+
+
+Example Configuration
+=====================
+
+Enable JA4 fingerprinting by hybrid (global + remap) setup
+----------------------------------------------------------
+
+This configuration adds x-my-ja4 header if a connection is established for either abc.example or xyz.example.
+
+**plugin.config**::
+
+    jax_fingerprint.so --method JA4 --servernames abc.example,xyz.example
+
+**remap.config**::
+
+    map / http://origin.example/ @plugin=jax_fingerprint.so @pparam=--method=JA4 @pparam=--header=x-my-ja4
diff --git a/plugins/experimental/CMakeLists.txt b/plugins/experimental/CMakeLists.txt
index 8d63d3356d..fe7897c585 100644
--- a/plugins/experimental/CMakeLists.txt
+++ b/plugins/experimental/CMakeLists.txt
@@ -68,6 +68,9 @@ endif()
 if(BUILD_JA4_FINGERPRINT)
   add_subdirectory(ja4_fingerprint)
 endif()
+if(BUILD_JAX_FINGERPRINT)
+  add_subdirectory(jax_fingerprint)
+endif()
 if(BUILD_MAGICK)
   add_subdirectory(magick)
 endif()
diff --git a/plugins/experimental/jax_fingerprint/CMakeLists.txt b/plugins/experimental/jax_fingerprint/CMakeLists.txt
new file mode 100644
index 0000000000..173eb5750b
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/CMakeLists.txt
@@ -0,0 +1,54 @@
+#######################
+#
+#  Licensed to the Apache Software Foundation (ASF) under one or more contributor license
+#  agreements.  See the NOTICE file distributed with this work for additional information regarding
+#  copyright ownership.  The ASF licenses this file to you under the Apache License, Version 2.0
+#  (the "License"); you may not use this file except in compliance with the License.  You may obtain
+#  a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software distributed under the License
+#  is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+#  or implied. See the License for the specific language governing permissions and limitations under
+#  the License.
+#
+#######################
+
+add_atsplugin(
+  jax_fingerprint
+  plugin.cc
+  context.cc
+  userarg.cc
+  header.cc
+  log.cc
+  ja3/ja3_method.cc
+  ja3/ja3_utils.cc
+  ja4/ja4_method.cc
+  ja4/ja4.cc
+  ja4/tls_client_hello_summary.cc
+  ja4h/ja4h_method.cc
+  ja4h/ja4h.cc
+  ja4h/datasource.cc
+)
+target_link_libraries(jax_fingerprint PRIVATE OpenSSL::Crypto OpenSSL::SSL)
+verify_global_plugin(jax_fingerprint)
+target_include_directories(jax_fingerprint BEFORE PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
+
+if(BUILD_TESTING)
+  add_executable(
+    test_jax
+    ja3/test_ja3.cc
+    ja3/ja3_utils.cc
+    ja4/test_ja4.cc
+    ja4/ja4.cc
+    ja4/tls_client_hello_summary.cc
+    ja4h/test_ja4h.cc
+    ja4h/ja4h.cc
+    ja4h/datasource.cc
+  )
+  target_link_libraries(test_jax PRIVATE Catch2::Catch2WithMain OpenSSL::Crypto OpenSSL::SSL)
+  target_include_directories(test_jax BEFORE PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
+
+  add_catch2_test(NAME test_jax COMMAND test_jax)
+endif()
diff --git a/plugins/experimental/jax_fingerprint/config.h b/plugins/experimental/jax_fingerprint/config.h
new file mode 100644
index 0000000000..3d49894bff
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/config.h
@@ -0,0 +1,70 @@
+/** @file
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+
+ */
+
+#pragma once
+
+#include "ts/ts.h"
+#include "plugin.h"
+#include "method.h"
+
+#include <string>
+#include <unordered_set>
+
+enum class Mode : int {
+  OVERWRITE,
+  KEEP,
+  APPEND,
+};
+
+enum class PluginType : int {
+  GLOBAL,
+  REMAP,
+};
+
+// This hash function enables looking up the set by a string_view without making a temporary string object.
+struct StringHash {
+  // Enable heterogeneous lookup
+  using is_transparent = void;
+
+  size_t
+  operator()(std::string_view sv) const
+  {
+    return std::hash<std::string_view>{}(sv);
+  }
+};
+
+struct PluginConfig {
+  PluginConfig() { Dbg(dbg_ctl, "New config (%p) has been created", this); }
+  ~PluginConfig() { Dbg(dbg_ctl, "Config for (%p) has been deleted", this); }
+
+  PluginType    plugin_type     = PluginType::GLOBAL;
+  Mode          mode            = Mode::OVERWRITE;
+  struct Method method          = {"uninitialized", Method::Type::CONNECTION_BASED, nullptr, nullptr};
+  std::string   header_name     = "";
+  std::string   via_header_name = "";
+  std::string   log_filename    = "";
+  int           user_arg_index  = -1;
+  TSCont        handler         = nullptr; // For remap plugin
+  bool          standalone      = false;
+  std::unordered_set<std::string, StringHash, std::equal_to<>> servernames;
+  TSTextLogObject                                              log_handle = nullptr;
+};
diff --git a/plugins/experimental/jax_fingerprint/context.cc b/plugins/experimental/jax_fingerprint/context.cc
new file mode 100644
index 0000000000..f9f6b06a1c
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/context.cc
@@ -0,0 +1,78 @@
+/** @file
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+ */
+
+#include "plugin.h"
+#include "context.h"
+
+JAxContext::JAxContext(const char *method_name, sockaddr const *s_sockaddr) : _method_name(method_name)
+{
+  _addr[0] = '\0';
+
+  if (s_sockaddr == nullptr) {
+    return;
+  }
+
+  switch (s_sockaddr->sa_family) {
+  case AF_INET:
+    inet_ntop(AF_INET, &reinterpret_cast<const sockaddr_in *>(s_sockaddr)->sin_addr, _addr, INET_ADDRSTRLEN);
+    break;
+  case AF_INET6:
+    inet_ntop(AF_INET6, &reinterpret_cast<const sockaddr_in6 *>(s_sockaddr)->sin6_addr, _addr, INET6_ADDRSTRLEN);
+    break;
+  case AF_UNIX:
+    strncpy(_addr, reinterpret_cast<const sockaddr_un *>(s_sockaddr)->sun_path, sizeof(_addr) - 1);
+    _addr[sizeof(_addr) - 1] = '\0';
+    break;
+  default:
+    break;
+  }
+  Dbg(dbg_ctl, "New context for %s (%p) has been created", this->_method_name, this);
+}
+
+JAxContext::~JAxContext()
+{
+  Dbg(dbg_ctl, "Context for %s (%p) has been deleted", this->_method_name, this);
+}
+
+const std::string &
+JAxContext::get_fingerprint() const
+{
+  return this->_fingerprint;
+}
+
+void
+JAxContext::set_fingerprint(const std::string &fingerprint)
+{
+  this->_fingerprint = fingerprint;
+  Dbg(dbg_ctl, "Fingerprint: %s", this->_fingerprint.c_str());
+}
+
+const char *
+JAxContext::get_addr() const
+{
+  return this->_addr;
+}
+
+const char *
+JAxContext::get_method_name() const
+{
+  return this->_method_name;
+}
diff --git a/plugins/experimental/jax_fingerprint/context.h b/plugins/experimental/jax_fingerprint/context.h
new file mode 100644
index 0000000000..2d3bdaff6e
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/context.h
@@ -0,0 +1,48 @@
+/** @file
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+
+ */
+
+#pragma once
+
+#include <arpa/inet.h>
+#include <netinet/in.h>
+#include <sys/un.h>
+#include <limits.h>
+
+#include <string>
+
+class JAxContext
+{
+public:
+  JAxContext(const char *name, sockaddr const *s_sockaddr);
+  ~JAxContext();
+
+  const std::string &get_fingerprint() const;
+  void               set_fingerprint(const std::string &fingerprint);
+
+  const char *get_addr() const;
+  const char *get_method_name() const;
+
+private:
+  std::string _fingerprint;
+  char        _addr[PATH_MAX + 1];
+  const char *_method_name;
+};
diff --git a/plugins/experimental/jax_fingerprint/header.cc b/plugins/experimental/jax_fingerprint/header.cc
new file mode 100644
index 0000000000..cb4debe5a3
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/header.cc
@@ -0,0 +1,162 @@
+/** @file
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+ */
+
+#include "plugin.h"
+#include "header.h"
+
+#include "ts/ts.h"
+
+#include <string>
+#include <string_view>
+
+static void
+put_header(TSHttpTxn txnp, const std::string &name, const std::string &value, bool overwrite)
+{
+  TSMBuffer bufp;
+  TSMLoc    hdr_loc;
+  if (TS_SUCCESS != TSHttpTxnClientReqGet(txnp, &bufp, &hdr_loc)) {
+    Dbg(dbg_ctl, "Failed to get headers.");
+    return;
+  }
+
+  TSMLoc target = TSMimeHdrFieldFind(bufp, hdr_loc, name.c_str(), name.length());
+  if (target == TS_NULL_MLOC) {
+    // Add - Create a new field with the value
+    Dbg(dbg_ctl, "Add %s: %s", name.c_str(), value.c_str());
+    TSMimeHdrFieldCreateNamed(bufp, hdr_loc, name.c_str(), name.length(), &target);
+    TSMimeHdrFieldValueStringSet(bufp, hdr_loc, target, -1, value.c_str(), value.length());
+    TSMimeHdrFieldAppend(bufp, hdr_loc, target);
+    TSHandleMLocRelease(bufp, hdr_loc, target);
+  } else if (overwrite) {
+    // Replace - Set the value to the first field and remove all duplicate fields
+    Dbg(dbg_ctl, "Replace %s field value with %s", name.c_str(), value.c_str());
+    TSMLoc tmp   = nullptr;
+    bool   first = true;
+    while (target) {
+      tmp = TSMimeHdrFieldNextDup(bufp, hdr_loc, target);
+      if (first) {
+        first = false;
+        TSMimeHdrFieldValueStringSet(bufp, hdr_loc, target, -1, value.c_str(), value.size());
+      } else {
+        TSMimeHdrFieldDestroy(bufp, hdr_loc, target);
+      }
+      TSHandleMLocRelease(bufp, hdr_loc, target);
+      target = tmp;
+    }
+  } else {
+    // Append - Find the last duplicate field and set the value to it
+    Dbg(dbg_ctl, "Append %s to %s field value", value.c_str(), name.c_str());
+    TSMLoc dup = TSMimeHdrFieldNextDup(bufp, hdr_loc, target);
+    while (dup != TS_NULL_MLOC) {
+      TSHandleMLocRelease(bufp, hdr_loc, target);
+      target = dup;
+      dup    = TSMimeHdrFieldNextDup(bufp, hdr_loc, target);
+    }
+    TSMimeHdrFieldValueStringInsert(bufp, hdr_loc, target, -1, value.c_str(), value.length());
+    TSHandleMLocRelease(bufp, hdr_loc, target);
+  }
+
+  TSHandleMLocRelease(bufp, TS_NULL_MLOC, hdr_loc);
+}
+
+static void
+put_via_header(TSHttpTxn txnp, const std::string &via_header, bool overwrite)
+{
+  TSMgmtString proxy_name = nullptr;
+  if (TS_SUCCESS == TSMgmtStringGet("proxy.config.proxy_name", &proxy_name)) {
+    put_header(txnp, via_header, proxy_name, overwrite);
+    TSfree(proxy_name);
+  } else {
+    TSError("[%s] Failed to get proxy name for %s, set 'proxy.config.proxy_name' in records.config", PLUGIN_NAME,
+            via_header.c_str());
+    put_header(txnp, via_header, "unknown", overwrite);
+  }
+}
+
+void
+set_header(TSHttpTxn txnp, const std::string &header, const std::string &fingerprint)
+{
+  put_header(txnp, header, fingerprint, true);
+}
+
+void
+append_header(TSHttpTxn txnp, const std::string &header, const std::string &fingerprint)
+{
+  put_header(txnp, header, fingerprint, false);
+}
+
+void
+set_via_header(TSHttpTxn txnp, const std::string &via_header)
+{
+  put_via_header(txnp, via_header, true);
+}
+
+void
+append_via_header(TSHttpTxn txnp, const std::string &via_header)
+{
+  put_via_header(txnp, via_header, false);
+}
+
+void
+remove_header(TSHttpTxn txnp, const std::string &header)
+{
+  TSMBuffer bufp;
+  TSMLoc    hdr_loc;
+  if (TS_SUCCESS != TSHttpTxnClientReqGet(txnp, &bufp, &hdr_loc)) {
+    Dbg(dbg_ctl, "Failed to get headers.");
+    return;
+  }
+
+  TSMLoc target = TSMimeHdrFieldFind(bufp, hdr_loc, header.c_str(), header.length());
+  if (target != TS_NULL_MLOC) {
+    // Remove all
+    Dbg(dbg_ctl, "Remove all %s field", header.c_str());
+    TSMLoc tmp = nullptr;
+    while (target) {
+      tmp = TSMimeHdrFieldNextDup(bufp, hdr_loc, target);
+      TSMimeHdrFieldDestroy(bufp, hdr_loc, target);
+      TSHandleMLocRelease(bufp, hdr_loc, target);
+      target = tmp;
+    }
+  }
+  TSHandleMLocRelease(bufp, TS_NULL_MLOC, hdr_loc);
+}
+
+bool
+has_header(TSHttpTxn txnp, const std::string &header)
+{
+  TSMBuffer bufp;
+  TSMLoc    hdr_loc;
+  if (TS_SUCCESS != TSHttpTxnClientReqGet(txnp, &bufp, &hdr_loc)) {
+    Dbg(dbg_ctl, "Failed to get headers.");
+    return false;
+  }
+
+  TSMLoc target = TSMimeHdrFieldFind(bufp, hdr_loc, header.c_str(), header.length());
+  if (target == TS_NULL_MLOC) {
+    TSHandleMLocRelease(bufp, TS_NULL_MLOC, hdr_loc);
+    return false;
+  } else {
+    TSHandleMLocRelease(bufp, hdr_loc, target);
+    TSHandleMLocRelease(bufp, TS_NULL_MLOC, hdr_loc);
+    return true;
+  }
+}
diff --git a/plugins/experimental/jax_fingerprint/header.h b/plugins/experimental/jax_fingerprint/header.h
new file mode 100644
index 0000000000..4fe8c0c19a
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/header.h
@@ -0,0 +1,34 @@
+/** @file
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+
+ */
+
+#pragma once
+
+#include "ts/ts.h"
+
+#include <string>
+
+void append_header(TSHttpTxn txnp, const std::string &header, const std::string &fingerprint);
+void append_via_header(TSHttpTxn txnp, const std::string &via_header);
+void set_header(TSHttpTxn txnp, const std::string &header, const std::string &fingerprint);
+void set_via_header(TSHttpTxn txnp, const std::string &via_header);
+void remove_header(TSHttpTxn txnp, const std::string &header);
+bool has_header(TSHttpTxn txnp, const std::string &header);
diff --git a/plugins/experimental/jax_fingerprint/ja3/ja3_method.cc b/plugins/experimental/jax_fingerprint/ja3/ja3_method.cc
new file mode 100644
index 0000000000..ef2e0a2160
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja3/ja3_method.cc
@@ -0,0 +1,128 @@
+/** @file
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+
+ */
+
+#include "ts/ts.h"
+
+#include <plugin.h>
+#include <context.h>
+#include "ja3_method.h"
+#include "ja3_utils.h"
+
+#include <openssl/ssl.h>
+#include <openssl/md5.h>
+#include <openssl/opensslv.h>
+
+#include <algorithm>
+
+namespace ja3_method
+{
+
+void on_client_hello(JAxContext *, TSVConn);
+
+struct Method method = {
+  "JA3",
+  Method::Type::CONNECTION_BASED,
+  on_client_hello,
+  nullptr,
+};
+
+} // namespace ja3_method
+
+namespace
+{
+constexpr int ja3_hash_included_byte_count{16};
+static_assert(ja3_hash_included_byte_count <= MD5_DIGEST_LENGTH);
+
+constexpr int ja3_hash_hex_string_with_null_terminator_length{2 * ja3_hash_included_byte_count + 1};
+
+} // end anonymous namespace
+
+static std::string
+get_fingerprint(TSClientHello ch)
+{
+  std::string          raw;
+  std::size_t          len{};
+  const unsigned char *buf{};
+
+  // Get version
+  unsigned int version = ch.get_version();
+  raw.append(std::to_string(version));
+  raw.push_back(',');
+
+  // Get cipher suites
+  raw.append(ja3::encode_word_buffer(ch.get_cipher_suites(), ch.get_cipher_suites_len()));
+  raw.push_back(',');
+
+  // Get extensions
+  auto ext_types = ch.get_extension_types();
+  len            = 0;
+  auto first     = ext_types.begin();
+  auto last      = ext_types.end();
+  while (first != last) {
+    ++first;
+    ++len;
+  }
+  if (len > 0) {
+    int extension_ids[len];
+    first = ext_types.begin();
+    for (size_t i = 0; i < len; ++i, ++first) {
+      extension_ids[i] = *first;
+    }
+    raw.append(ja3::encode_integer_buffer(extension_ids, len));
+  }
+  raw.push_back(',');
+
+  // Get elliptic curves
+  if (TS_SUCCESS == TSClientHelloExtensionGet(ch, 0x0a, &buf, &len) && len >= 2) {
+    // Skip first 2 bytes since we already have length
+    raw.append(ja3::encode_word_buffer(buf + 2, len - 2));
+  }
+  raw.push_back(',');
+
+  // Get elliptic curve point formats
+  if (TS_SUCCESS == TSClientHelloExtensionGet(ch, 0x0b, &buf, &len) && len >= 2) {
+    // Skip first byte since we already have length
+    raw.append(ja3::encode_byte_buffer(buf + 1, len - 1));
+  }
+  Dbg(dbg_ctl, "Hashing %s", raw.c_str());
+
+  char          fingerprint[ja3_hash_hex_string_with_null_terminator_length];
+  unsigned char digest[MD5_DIGEST_LENGTH];
+  MD5(reinterpret_cast<unsigned char const *>(raw.c_str()), raw.length(), digest);
+  for (int i{0}; i < ja3_hash_included_byte_count; ++i) {
+    std::snprintf(&(fingerprint[i * 2]), sizeof(fingerprint) - (i * 2), "%02x", static_cast<unsigned int>(digest[i]));
+  }
+
+  return {fingerprint};
+}
+
+void
+ja3_method::on_client_hello(JAxContext *ctx, TSVConn vconn)
+{
+  TSClientHello ch = TSVConnClientHelloGet(vconn);
+
+  if (!ch) {
+    Dbg(dbg_ctl, "Could not get TSClientHello object.");
+  } else {
+    ctx->set_fingerprint(get_fingerprint(ch));
+  }
+}
diff --git a/plugins/experimental/jax_fingerprint/ja3/ja3_method.h b/plugins/experimental/jax_fingerprint/ja3/ja3_method.h
new file mode 100644
index 0000000000..0797113341
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja3/ja3_method.h
@@ -0,0 +1,32 @@
+/** @file
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+
+ */
+
+#pragma once
+
+#include <method.h>
+
+namespace ja3_method
+{
+
+extern struct Method method;
+
+}
diff --git a/plugins/experimental/jax_fingerprint/ja3/ja3_utils.cc b/plugins/experimental/jax_fingerprint/ja3/ja3_utils.cc
new file mode 100644
index 0000000000..b54e5066c8
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja3/ja3_utils.cc
@@ -0,0 +1,108 @@
+/** @file ja3_utils.cc
+
+  Plugin JA3 Fingerprint calculates JA3 signatures for incoming SSL traffic.
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+
+ */
+
+#include <algorithm>
+#include <cstdint>
+#include <string>
+#include <unordered_set>
+
+namespace ja3
+{
+
+// GREASE table as in ja3
+static std::unordered_set<std::uint16_t> const GREASE_table = {0x0a0a, 0x1a1a, 0x2a2a, 0x3a3a, 0x4a4a, 0x5a5a, 0x6a6a, 0x7a7a,
+                                                               0x8a8a, 0x9a9a, 0xaaaa, 0xbaba, 0xcaca, 0xdada, 0xeaea, 0xfafa};
+
+static constexpr std::uint16_t
+from_big_endian(unsigned char lowbyte, unsigned char highbyte)
+{
+  return (static_cast<std::uint16_t>(lowbyte) << 8) | highbyte;
+}
+
+static bool
+ja3_should_ignore(std::uint16_t n)
+{
+  return GREASE_table.find(n) != GREASE_table.end();
+}
+
+std::string
+encode_byte_buffer(unsigned char const *buf, int const len)
+{
+  std::string result;
+  if (len > 0) {
+    // Benchmarks show that reserving space in the string here would cause
+    // a 40% increase in runtime for a buffer with 10 elements... so we
+    // don't do it.
+    result.append(std::to_string(buf[0]));
+    std::for_each(buf + 1, buf + len, [&result](unsigned char i) {
+      result.push_back('-');
+      result.append(std::to_string(i));
+    });
+  }
+  return result;
+}
+
+std::string
+encode_word_buffer(unsigned char const *buf, int const len)
+{
+  std::string result;
+  auto        it{buf};
+  while ((it < (buf + len)) && ja3_should_ignore(from_big_endian(it[0], it[1]))) {
+    it += 2;
+  }
+  if (it < (buf + len)) {
+    // Benchmarks show that reserving buf.size() - 1 space in the string here
+    // would have no impact on performance. Since the string may not even need
+    // that much due to GREASE values present in the buffer, we don't do it.
+    result.append(std::to_string(from_big_endian(it[0], it[1])));
+    it += 2;
+    for (; it < buf + len; it += 2) {
+      auto const value{from_big_endian(it[0], it[1])};
+      if (!ja3_should_ignore(value)) {
+        result.push_back('-');
+        result.append(std::to_string(value));
+      }
+    }
+  }
+  return result;
+}
+
+std::string
+encode_integer_buffer(int const *buf, int const len)
+{
+  std::string result;
+  auto        it{std::find_if(buf, buf + len, [](int i) { return !ja3_should_ignore(i); })};
+  if (it < (buf + len)) {
+    result.append(std::to_string(*it));
+    std::for_each(it + 1, buf + len, [&result](int const i) {
+      if (!ja3_should_ignore(i)) {
+        result.push_back('-');
+        result.append(std::to_string(i));
+      }
+    });
+  }
+  return result;
+}
+
+} // end namespace ja3
diff --git a/plugins/experimental/jax_fingerprint/ja3/ja3_utils.h b/plugins/experimental/jax_fingerprint/ja3/ja3_utils.h
new file mode 100644
index 0000000000..3a858d4b26
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja3/ja3_utils.h
@@ -0,0 +1,73 @@
+/** @file ja3_utils.h
+
+  Plugin JA3 Fingerprint calculates JA3 signatures for incoming SSL traffic.
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+
+ */
+
+#pragma once
+
+#include <string>
+
+namespace ja3
+{
+
+/** Encode a buffer of 8bit values.
+ *
+ * The values will be converted to their decimal string representations and
+ * joined with the '-' character.
+ *
+ * @param buf The buffer to encode. This should be an SSL buffer of 8bit
+ *  values.
+ * @param len The length of the buffer. If the length is zero, buf will
+ *  not be dereferenced.
+ * @return The string-encoded ja3 representation of the buffer.
+ */
+std::string encode_byte_buffer(unsigned char const *buf, int const len);
+
+/** Encode a buffer of big-endian 16bit values.
+ *
+ * The values will be converted to their decimal string representations and
+ * joined with the '-' character. Any GREASE values in the buffer will be
+ * ignored.
+ *
+ * @param buf The buffer to encode. This should be a big-endian SSL buffer
+ *  of 16bit values.
+ * @param len The length of the buffer. If the length is zero, buf will not
+ *  be dereferenced.
+ * @return The string-encoded ja3 representation of the buffer.
+ */
+std::string encode_word_buffer(unsigned char const *buf, int const len);
+
+/** Encode a buffer of integers.
+ *
+ * The values will be converted to their decimal string representations and
+ * joined with the '-' character. Any GREASE values in the buffer will be
+ * ignored.
+ *
+ * @param buf The buffer to encode. The buffer underlying the span should be
+ *  an SSL buffer of ints.
+ * @param len The length (number of values) in the buffer. If the length is
+ *  zero, buf will not be dereferenced.
+ * @return The string-encoded ja3 representation of the buffer.
+ */
+std::string encode_integer_buffer(int const *buf, int const len);
+
+} // end namespace ja3
diff --git a/plugins/experimental/jax_fingerprint/ja3/test_ja3.cc b/plugins/experimental/jax_fingerprint/ja3/test_ja3.cc
new file mode 100644
index 0000000000..1e5de5e25d
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja3/test_ja3.cc
@@ -0,0 +1,96 @@
+/** @file test_utils.cc
+
+  Unit tests for ja3.
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+
+ */
+
+#include "ja3_utils.h"
+
+#include <catch2/catch_test_macros.hpp>
+
+TEST_CASE("ja3 byte buffer encoding")
+{
+  unsigned char const buf[]{0x8, 0x3, 0x4};
+
+  SECTION("empty buffer")
+  {
+    auto got{ja3::encode_byte_buffer(nullptr, 0)};
+    CHECK("" == got);
+  }
+
+  SECTION("1 value")
+  {
+    auto got{ja3::encode_byte_buffer(buf, 1)};
+    CHECK("8" == got);
+  }
+
+  SECTION("3 values")
+  {
+    auto got{ja3::encode_byte_buffer(buf, 3)};
+    CHECK("8-3-4" == got);
+  }
+}
+
+TEST_CASE("ja3 word buffer encoding")
+{
+  unsigned char const buf[]{0x0, 0x5, 0x0a, 0x0a, 0x0, 0x8, 0xda, 0xda, 0x1, 0x0};
+
+  SECTION("empty buffer")
+  {
+    auto got{ja3::encode_word_buffer(nullptr, 0)};
+    CHECK("" == got);
+  }
+
+  SECTION("1 value")
+  {
+    auto got{ja3::encode_word_buffer(buf, 2)};
+    CHECK("5" == got);
+  }
+
+  SECTION("5 values including GREASE values")
+  {
+    auto got{ja3::encode_word_buffer(buf, 10)};
+    CHECK("5-8-256" == got);
+  }
+}
+
+TEST_CASE("ja3 integer buffer encoding")
+{
+  int const buf[]{5, 2570, 8, 56026, 256};
+
+  SECTION("empty buffer")
+  {
+    auto got{ja3::encode_integer_buffer(nullptr, 0)};
+    CHECK("" == got);
+  }
+
+  SECTION("1 value")
+  {
+    auto got{ja3::encode_integer_buffer(buf, 1)};
+    CHECK("5" == got);
+  }
+
+  SECTION("5 values including GREASE values")
+  {
+    auto got{ja3::encode_integer_buffer(buf, 5)};
+    CHECK("5-8-256" == got);
+  }
+}
diff --git a/plugins/experimental/jax_fingerprint/ja4/ja4.cc b/plugins/experimental/jax_fingerprint/ja4/ja4.cc
new file mode 100644
index 0000000000..62a6c08fa4
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4/ja4.cc
@@ -0,0 +1,175 @@
+/** @file
+ *
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+
+ */
+
+#include "ja4.h"
+
+#include <algorithm>
+#include <cctype>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <iterator>
+#include <string>
+#include <string_view>
+
+static char        convert_protocol_to_char(JA4::Protocol protocol);
+static std::string convert_TLS_version_to_string(std::uint16_t TLS_version);
+static char        convert_SNI_to_char(JA4::SNI SNI_type);
+static std::string convert_count_to_two_digit_string(std::size_t count);
+static std::string convert_ALPN_to_two_char_string(std::string_view ALPN);
+static void        remove_trailing_character(std::string &s);
+static std::string hexify(std::uint16_t n);
+
+namespace
+{
+constexpr std::size_t U16_HEX_BUF_SIZE{4};
+} // end anonymous namespace
+
+std::string
+JA4::make_JA4_a_raw(TLSClientHelloSummary const &TLS_summary)
+{
+  std::string result;
+  result.reserve(9);
+  result.push_back(convert_protocol_to_char(TLS_summary.protocol));
+  result.append(convert_TLS_version_to_string(TLS_summary.TLS_version));
+  result.push_back(convert_SNI_to_char(TLS_summary.get_SNI_type()));
+  result.append(convert_count_to_two_digit_string(TLS_summary.get_cipher_count()));
+  result.append(convert_count_to_two_digit_string(TLS_summary.get_extension_count()));
+  result.append(convert_ALPN_to_two_char_string(TLS_summary.ALPN));
+  return result;
+}
+
+static char
+convert_protocol_to_char(JA4::Protocol protocol)
+{
+  return static_cast<char>(protocol);
+}
+
+static std::string
+convert_TLS_version_to_string(std::uint16_t TLS_version)
+{
+  switch (TLS_version) {
+  case 0x304:
+    return "13";
+  case 0x303:
+    return "12";
+  case 0x302:
+    return "11";
+  case 0x301:
+    return "10";
+  case 0x300:
+    return "s3";
+  case 0x200:
+    return "s2";
+  case 0x100:
+    return "s1";
+  case 0xfeff:
+    return "d1";
+  case 0xfefd:
+    return "d2";
+  case 0xfefc:
+    return "d3";
+  default:
+    return "00";
+  }
+}
+
+static char
+convert_SNI_to_char(JA4::SNI SNI_type)
+{
+  return static_cast<char>(SNI_type);
+}
+
+static std::string
+convert_count_to_two_digit_string(std::size_t count)
+{
+  std::string result;
+  if (count <= 9) {
+    result.push_back('0');
+  }
+  // We could also clamp the lower bound to 1 since there must be at least 1
+  // cipher, but 0 is more helpful for debugging if the cipher list is empty.
+  result.append(std::to_string(std::clamp(count, std::size_t{0}, std::size_t{99})));
+  return result;
+}
+
+static std::string
+convert_ALPN_to_two_char_string(std::string_view ALPN)
+{
+  std::string result;
+  if (ALPN.empty()) {
+    result = "00";
+  } else {
+    result.push_back(ALPN.front());
+    result.push_back(ALPN.back());
+  }
+  return result;
+}
+
+std::string
+JA4::make_JA4_b_raw(TLSClientHelloSummary const &TLS_summary)
+{
+  std::string result;
+  result.reserve(12);
+  std::vector temp = TLS_summary.get_ciphers();
+  std::sort(temp.begin(), temp.end());
+
+  for (auto cipher : temp) {
+    result.append(hexify(cipher));
+    result.push_back(',');
+  }
+  remove_trailing_character(result);
+  return result;
+}
+
+std::string
+JA4::make_JA4_c_raw(TLSClientHelloSummary const &TLS_summary)
+{
+  std::string result;
+  result.reserve(12);
+  std::vector temp = TLS_summary.get_extensions();
+  std::sort(temp.begin(), temp.end());
+
+  for (auto extension : temp) {
+    result.append(hexify(extension));
+    result.push_back(',');
+  }
+  remove_trailing_character(result);
+  return result;
+}
+
+static void
+remove_trailing_character(std::string &s)
+{
+  if (!s.empty()) {
+    s.pop_back();
+  }
+}
+
+static std::string
+hexify(std::uint16_t n)
+{
+  char result[U16_HEX_BUF_SIZE + 1]{};
+  std::snprintf(result, sizeof(result), "%.4x", n);
+  return result;
+}
diff --git a/plugins/experimental/jax_fingerprint/ja4/ja4.h b/plugins/experimental/jax_fingerprint/ja4/ja4.h
new file mode 100644
index 0000000000..b6be1e40cf
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4/ja4.h
@@ -0,0 +1,171 @@
+/** @file
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+
+ */
+
+#pragma once
+
+#include <cstdint>
+#include <iterator>
+#include <string>
+#include <vector>
+
+namespace JA4
+{
+
+constexpr char PORTION_DELIMITER{'_'};
+
+enum class Protocol {
+  DTLS = 'd',
+  QUIC = 'q',
+  TLS  = 't',
+};
+
+enum class SNI {
+  to_domain = 'd',
+  to_IP     = 'i',
+};
+
+/**
+ * Represents the data sent in a TLS Client Hello needed for JA4 fingerprints.
+ */
+class TLSClientHelloSummary
+{
+public:
+  using difference_type = std::iterator_traits<std::vector<std::uint16_t>::iterator>::difference_type;
+
+  Protocol      protocol;
+  std::uint16_t TLS_version{0}; // 0 is not the default, this is only to not have it un-initialized.
+  std::string   ALPN;
+
+  std::vector<std::uint16_t> const &get_ciphers() const;
+  void                              add_cipher(std::uint16_t cipher);
+
+  std::vector<std::uint16_t> const &get_extensions() const;
+  void                              add_extension(std::uint16_t extension);
+
+  /**
+   * Get the number of ciphers excluding GREASE values.
+   *
+   * @return Returns the count of non-GREASE ciphers.
+   */
+  difference_type get_cipher_count() const;
+
+  /**
+   * Get the number of extensions excluding GREASE values.
+   *
+   * @return Returns the count of non-GREASE extensions.
+   */
+  difference_type get_extension_count() const;
+
+  /** Get the SNI type, domain or IP.
+   *
+   * @return Returns SNI::to_domain or SNI::to_IP.
+   */
+  SNI get_SNI_type() const;
+
+private:
+  std::vector<std::uint16_t> _ciphers;
+  std::vector<std::uint16_t> _extensions;
+  int                        _extension_count_including_sni_and_alpn{0};
+  SNI                        _SNI_type{SNI::to_IP};
+};
+
+/**
+ * Calculate the a portion of the JA4 fingerprint for the given client hello.
+ *
+ * The a portion of the fingerprint encodes the protocol, TLS version, SNI
+ * type, number of cipher suites, number of extensions, and first ALPN value.
+ *
+ * For more information see:
+ * https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/JA4.md.
+ *
+ * @param TLS_summary The TLS client hello.
+ * @return Returns a string containing the a portion of the JA4 fingerprint.
+ */
+std::string make_JA4_a_raw(TLSClientHelloSummary const &TLS_summary);
+
+/**
+ * Calculate the b portion of the JA4 fingerprint for the given client hello.
+ *
+ * The b portion of the fingerprint is a comma-delimited list of lowercase hex
+ * numbers representing the cipher suites in sorted order. GREASE values are
+ * ignored.
+ *
+ * For more information see:
+ * https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/JA4.md.
+ *
+ * @param TLS_summary The TLS client hello.
+ * @return Returns a string containing the b portion of the JA4 fingerprint.
+ */
+std::string make_JA4_b_raw(TLSClientHelloSummary const &TLS_summary);
+
+/**
+ * Calculate the c portion of the JA4 fingerprint for the given client hello.
+ *
+ * The b portion of the fingerprint is a comma-delimited list of lowercase hex
+ * numbers representing the extensions in sorted order. GREASE values and the
+ * SNI and ALPN extensions are ignored.
+ *
+ * For more information see:
+ * https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/JA4.md.
+ *
+ * @param TLS_summary The TLS client hello.
+ * @return Returns a string containing the c portion of the JA4 fingerprint.
+ */
+std::string make_JA4_c_raw(TLSClientHelloSummary const &TLS_summary);
+
+/**
+ * Calculate the JA4 fingerprint for the given TLS client hello.
+ *
+ * @param TLS_summary The TLS client hello. If there was no ALPN in the
+ * Client Hello, TLS_summary.ALPN should either be empty or set to "00".
+ * Behavior when the number of digits in TLS_summary.TLS_version is greater
+ * than 2, the number of digits in TLS_summary.ALPN is greater than 2
+ * (except when TLS_summary.ALPN is empty) is unspecified.
+ * @param UnaryOp hasher A hash function. For a specification-compliant
+ * JA4 fingerprint, this should be a sha256 hash.
+ * @return Returns a string containing the JA4 fingerprint.
+ */
+template <typename UnaryOp>
+std::string
+make_JA4_fingerprint(TLSClientHelloSummary const &TLS_summary, UnaryOp hasher)
+{
+  std::string result;
+  result.append(make_JA4_a_raw(TLS_summary));
+  result.push_back(JA4::PORTION_DELIMITER);
+  result.append(hasher(make_JA4_b_raw(TLS_summary)).substr(0, 12));
+  result.push_back(JA4::PORTION_DELIMITER);
+  result.append(hasher(make_JA4_c_raw(TLS_summary)).substr(0, 12));
+  return result;
+}
+
+/**
+ * Check whether @a value is a GREASE value.
+ *
+ * These are reserved extensions randomly advertised to keep implementations
+ * well lubricated. They are ignored in all parts of JA4 because of their
+ * random nature.
+ *
+ * @return Returns true if the value is a GREASE value, false otherwise.
+ */
+bool is_GREASE(std::uint16_t value);
+
+} // end namespace JA4
diff --git a/plugins/experimental/jax_fingerprint/ja4/ja4_method.cc b/plugins/experimental/jax_fingerprint/ja4/ja4_method.cc
new file mode 100644
index 0000000000..7fcaaf2edc
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4/ja4_method.cc
@@ -0,0 +1,159 @@
+/** @file
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+ */
+
+#include "ts/ts.h"
+
+#include <plugin.h>
+#include <context.h>
+#include "ja4_method.h"
+#include "ja4.h"
+
+#include <openssl/sha.h>
+#include <openssl/ssl.h>
+
+constexpr unsigned int EXT_ALPN{0x10};
+constexpr unsigned int EXT_SUPPORTED_VERSIONS{0x2b};
+
+namespace ja4_method
+{
+
+void on_client_hello(JAxContext *, TSVConn);
+
+struct Method method = {
+  "JA4",
+  Method::Type::CONNECTION_BASED,
+  on_client_hello,
+  nullptr,
+};
+
+} // namespace ja4_method
+
+static std::uint16_t
+get_version(TSClientHello ch)
+{
+  unsigned char const *buf{};
+  std::size_t          buflen{};
+  if (TS_SUCCESS == TSClientHelloExtensionGet(ch, EXT_SUPPORTED_VERSIONS, &buf, &buflen)) {
+    std::uint16_t max_version{0};
+    size_t        versions_len = buf[0];
+
+    if (buflen < versions_len + 1) {
+      Dbg(dbg_ctl, "Malformed supported_versions extension (truncated vector)... using legacy version.");
+      return ch.get_version();
+    }
+
+    for (size_t i = 1; (i + 1) < (versions_len + 1); i += 2) {
+      std::uint16_t version = (buf[i] << 8) | buf[i + 1];
+      if (!JA4::is_GREASE(version) && version > max_version) {
+        max_version = version;
+      }
+    }
+    return max_version;
+  } else {
+    Dbg(dbg_ctl, "No supported_versions extension... using legacy version.");
+    return ch.get_version();
+  }
+}
+
+static std::string
+get_first_ALPN(TSClientHello ch)
+{
+  unsigned char const *buf{};
+  std::size_t          buflen{};
+  std::string          result{""};
+  if (TS_SUCCESS == TSClientHelloExtensionGet(ch, EXT_ALPN, &buf, &buflen)) {
+    // The first two bytes are a 16bit encoding of the total length.
+    TSAssert(buflen > 4);
+    unsigned char first_ALPN_length{buf[2]};
+    TSAssert(0 != first_ALPN_length);
+    result.assign(&buf[3], (&buf[3]) + first_ALPN_length);
+  }
+
+  return result;
+}
+
+static constexpr std::uint16_t
+make_word(unsigned char lowbyte, unsigned char highbyte)
+{
+  return (static_cast<std::uint16_t>(highbyte) << 8) | lowbyte;
+}
+
+static void
+add_ciphers(JA4::TLSClientHelloSummary &summary, TSClientHello ch)
+{
+  const uint8_t *buf    = ch.get_cipher_suites();
+  size_t         buflen = ch.get_cipher_suites_len();
+
+  if (buflen > 0) {
+    for (std::size_t i = 0; i + 1 < buflen; i += 2) {
+      summary.add_cipher(make_word(buf[i], buf[i + 1]));
+    }
+  } else {
+    Dbg(dbg_ctl, "Failed to get ciphers.");
+  }
+}
+
+static void
+add_extensions(JA4::TLSClientHelloSummary &summary, TSClientHello ch)
+{
+  for (auto ext_type : ch.get_extension_types()) {
+    summary.add_extension(ext_type);
+  }
+}
+
+static std::string
+hash_with_SHA256(std::string_view sv)
+{
+  Dbg(dbg_ctl, "Hashing %s", std::string{sv}.c_str());
+  unsigned char hash[SHA256_DIGEST_LENGTH];
+  SHA256(reinterpret_cast<unsigned char const *>(sv.data()), sv.size(), hash);
+  std::string result;
+  result.resize(SHA256_DIGEST_LENGTH * 2 + 1);
+  for (int i{0}; i < SHA256_DIGEST_LENGTH; ++i) {
+    std::snprintf(result.data() + (i * 2), result.size() - (i * 2), "%02x", hash[i]);
+  }
+  return result;
+}
+
+static std::string
+get_fingerprint(TSClientHello ch)
+{
+  JA4::TLSClientHelloSummary summary{};
+  summary.protocol    = JA4::Protocol::TLS;
+  summary.TLS_version = get_version(ch);
+  summary.ALPN        = get_first_ALPN(ch);
+  add_ciphers(summary, ch);
+  add_extensions(summary, ch);
+  std::string result{JA4::make_JA4_fingerprint(summary, hash_with_SHA256)};
+  return result;
+}
+
+void
+ja4_method::on_client_hello(JAxContext *ctx, TSVConn vconn)
+{
+  TSClientHello ch = TSVConnClientHelloGet(vconn);
+
+  if (!ch) {
+    Dbg(dbg_ctl, "Could not get TSClientHello object.");
+  } else {
+    ctx->set_fingerprint(get_fingerprint(ch));
+  }
+}
diff --git a/plugins/experimental/jax_fingerprint/ja4/ja4_method.h b/plugins/experimental/jax_fingerprint/ja4/ja4_method.h
new file mode 100644
index 0000000000..4b7abea288
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4/ja4_method.h
@@ -0,0 +1,32 @@
+/** @file
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+
+ */
+
+#pragma once
+
+#include <method.h>
+
+namespace ja4_method
+{
+
+extern struct Method method;
+
+}
diff --git a/plugins/experimental/jax_fingerprint/ja4/test_ja4.cc b/plugins/experimental/jax_fingerprint/ja4/test_ja4.cc
new file mode 100644
index 0000000000..8860d105f1
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4/test_ja4.cc
@@ -0,0 +1,425 @@
+/** @file
+ *
+  Unit tests for JA4 fingerprint calculation.
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+
+ */
+
+#include "ja4.h"
+
+#include <catch2/catch_test_macros.hpp>
+
+#include <algorithm>
+#include <cctype>
+#include <string>
+#include <string_view>
+#include <unordered_map>
+
+static std::string call_JA4(JA4::TLSClientHelloSummary const &TLS_summary);
+static std::string inc(std::string_view sv);
+
+TEST_CASE("JA4")
+{
+  JA4::TLSClientHelloSummary TLS_summary{};
+
+  SECTION("Given the protocol is TCP, "
+          "when we create a JA4 fingerprint, "
+          "then the first character thereof should be 't'.")
+  {
+    TLS_summary.protocol = JA4::Protocol::TLS;
+
+    CHECK("t" == call_JA4(TLS_summary).substr(0, 1));
+  }
+
+  SECTION("Given the protocol is QUIC, "
+          "when we create a JA4 fingerprint, "
+          "then the first character thereof should be 'q'.")
+  {
+    TLS_summary.protocol = JA4::Protocol::QUIC;
+    CHECK(call_JA4(TLS_summary).starts_with('q'));
+  }
+
+  SECTION("Given the protocol is DTLS, "
+          "when we create a JA4 fingerprint, "
+          "then the first character thereof should be 'd'.")
+  {
+    TLS_summary.protocol = JA4::Protocol::DTLS;
+    CHECK(call_JA4(TLS_summary).starts_with('d'));
+  }
+
+  SECTION("Given the TLS version is unknown, "
+          "when we create a JA4 fingerprint, "
+          "then indices [1,2] thereof should contain \"00\".")
+  {
+    TLS_summary.TLS_version = 0x123;
+    CHECK("00" == call_JA4(TLS_summary).substr(1, 2));
+    TLS_summary.TLS_version = 0x234;
+    CHECK("00" == call_JA4(TLS_summary).substr(1, 2));
+  }
+
+  SECTION("Given the TLS version is known, "
+          "when we create a JA4 fingerprint, "
+          "then indices [1,2] thereof should contain the correct value.")
+  {
+    std::unordered_map<std::uint16_t, std::string> values{
+      {0x304,  "13"},
+      {0x303,  "12"},
+      {0x302,  "11"},
+      {0x301,  "10"},
+      {0x300,  "s3"},
+      {0x200,  "s2"},
+      {0x100,  "s1"},
+      {0xfeff, "d1"},
+      {0xfefd, "d2"},
+      {0xfefc, "d3"}
+    };
+    for (auto const &[version, expected] : values) {
+      CAPTURE(version, expected);
+      TLS_summary.TLS_version = version;
+      CHECK(expected == call_JA4(TLS_summary).substr(1, 2));
+    }
+  }
+
+  SECTION("Given the SNI extension is present, "
+          "when we create a JA4 fingerprint, "
+          "then index 3 thereof should contain 'd'.")
+  {
+    TLS_summary.add_extension(0x0);
+    CHECK("d" == call_JA4(TLS_summary).substr(3, 1));
+  }
+
+  SECTION("Given the SNI extension is not present, "
+          "when we create a JA4 fingerprint, "
+          "then index 3 thereof should contain 'i'.")
+  {
+    TLS_summary.add_extension(0x31);
+    CHECK("i" == call_JA4(TLS_summary).substr(3, 1));
+  }
+
+  SECTION("Given there is one cipher, "
+          "when we create a JA4 fingerprint, "
+          "then indices [4,5] thereof should contain \"01\".")
+  {
+    TLS_summary.add_cipher(1);
+    CHECK("01" == call_JA4(TLS_summary).substr(4, 2));
+  }
+
+  SECTION("Given there are 9 ciphers, "
+          "when we create a JA4 fingerprint, "
+          "then indices [4,5] thereof should contain \"09\".")
+  {
+    for (int i{0}; i < 9; ++i) {
+      TLS_summary.add_cipher(i);
+    }
+    CHECK("09" == call_JA4(TLS_summary).substr(4, 2));
+  }
+
+  SECTION("Given there are 10 ciphers, "
+          "when we create a JA4 fingerprint, "
+          "then indices [4,5] thereof should contain \"10\".")
+  {
+    for (int i{0}; i < 10; ++i) {
+      TLS_summary.add_cipher(i);
+    }
+    CHECK("10" == call_JA4(TLS_summary).substr(4, 2));
+  }
+
+  SECTION("Given there are more than 99 ciphers, "
+          "when we create a JA4 fingerprint, "
+          "then indices [4,5] thereof should contain \"99\".")
+  {
+    for (int i{0}; i < 100; ++i) {
+      TLS_summary.add_cipher(i);
+    }
+    CHECK("99" == call_JA4(TLS_summary).substr(4, 2));
+  }
+
+  SECTION("Given the ciphers include a GREASE value, "
+          "when we create a JA4 fingerprint, "
+          "then that value should not be included in the count.")
+  {
+    TLS_summary.add_cipher(0x0a0a);
+    TLS_summary.add_cipher(72);
+    CHECK("01" == call_JA4(TLS_summary).substr(4, 2));
+  }
+
+  SECTION("Given there are no extensions, "
+          "when we create a JA4 fingerprint, "
+          "then indices [6,7] thereof should contain \"00\".")
+  {
+    CHECK("00" == call_JA4(TLS_summary).substr(6, 2));
+  }
+
+  SECTION("Given there are 9 extensions, "
+          "when we create a JA4 fingerprint, "
+          "then indices [6,7] thereof should contain \"09\".")
+  {
+    for (int i{0}; i < 9; ++i) {
+      TLS_summary.add_extension(i);
+    }
+    CHECK("09" == call_JA4(TLS_summary).substr(6, 2));
+  }
+
+  SECTION("Given there are 99 extensions, "
+          "when we create a JA4 fingerprint, "
+          "then indices [6,7] thereof should contain \"99\".")
+  {
+    for (int i{0}; i < 99; ++i) {
+      TLS_summary.add_extension(i);
+    }
+    CHECK("99" == call_JA4(TLS_summary).substr(6, 2));
+  }
+
+  SECTION("Given there are more than 99 extensions, "
+          "when we create a JA4 fingerprint, "
+          "then indices [6,7] thereof should contain \"99\".")
+  {
+    for (int i{0}; i < 100; ++i) {
+      TLS_summary.add_extension(i);
+    }
+    CHECK("99" == call_JA4(TLS_summary).substr(6, 2));
+  }
+
+  SECTION("Given the extensions include a GREASE value, "
+          "when we create a JA4 fingerprint, "
+          "then that value should not be included in the count.")
+  {
+    TLS_summary.add_extension(2);
+    TLS_summary.add_extension(0x0a0a);
+    CHECK("01" == call_JA4(TLS_summary).substr(6, 2));
+  }
+
+  // These may be covered by the earlier tests as well, but this documents the
+  // behavior explicitly.
+  SECTION("When we create a JA4 fingerprint, "
+          "then the SNI and ALPN extensions should be included in the count.")
+  {
+    TLS_summary.add_extension(0x0);
+    TLS_summary.add_extension(0x10);
+    CHECK("02" == call_JA4(TLS_summary).substr(6, 2));
+  }
+
+  SECTION("Given the ALPN value is empty, "
+          "when we create a JA4 fingerprint, "
+          "then indices [8,9] thereof should contain \"00\".")
+  {
+    TLS_summary.ALPN = "";
+    CHECK("00" == call_JA4(TLS_summary).substr(8, 2));
+  }
+
+  // This should never happen in practice because all registered ALPN values
+  // are at least 2 characters long, but it's the correct behavior according
+  // to the spec. :-)
+  SECTION("Given the ALPN value is \"a\", "
+          "when we create a JA4 fingerprint, "
+          "then indices [8,9] thereof should contain \"aa\".")
+  {
+    TLS_summary.ALPN = 'a';
+    CHECK("aa" == call_JA4(TLS_summary).substr(8, 2));
+  }
+
+  SECTION("Given the ALPN value is \"h3\", "
+          "when we create a JA4 fingerprint, "
+          "then indices [8,9] thereof should contain \"h3\".")
+  {
+    TLS_summary.ALPN = "h3";
+    CHECK("h3" == call_JA4(TLS_summary).substr(8, 2));
+  }
+
+  SECTION("Given the ALPN value is \"imap\", "
+          "when we create a JA4 fingerprint, "
+          "then indices [8,9] thereof should contain \"ip\".")
+  {
+    TLS_summary.ALPN = "imap";
+    CHECK("ip" == call_JA4(TLS_summary).substr(8, 2));
+  }
+
+  SECTION("When we create a JA4 fingeprint, "
+          "then index 10 thereof should contain '_'.")
+  {
+    CHECK("_" == call_JA4(TLS_summary).substr(10, 1));
+  }
+
+  SECTION("When we create a JA4 fingerprint, "
+          "then the b section should be passed through the hash function.")
+  {
+    TLS_summary.add_cipher(10);
+    CHECK("111b" == JA4::make_JA4_fingerprint(TLS_summary, [](std::string_view sv) { return inc(sv); }).substr(11, 4));
+  }
+
+  // As per the spec, we expect 4-character, comma-delimited hex values.
+  SECTION("Given only ciphers 2, 12, and 17 in that order, "
+          "when we create a JA4 fingerprint, "
+          "then the hash should be invoked with \"0002,000c,0011\".")
+  {
+    TLS_summary.add_cipher(2);
+    TLS_summary.add_cipher(12);
+    TLS_summary.add_cipher(17);
+    bool verified{false};
+    // INFO doesn't work from inside the lambda body. :/
+    JA4::make_JA4_fingerprint(TLS_summary, [&verified](std::string_view sv) {
+      if ("0002,000c,0011" == sv) {
+        verified = true;
+      }
+      return sv;
+    });
+    CHECK(verified);
+  }
+
+  SECTION("When we create a JA4 fingerprint, "
+          "then the cipher values should be sorted before hashing.")
+  {
+    TLS_summary.add_cipher(17);
+    TLS_summary.add_cipher(2);
+    TLS_summary.add_cipher(12);
+    bool verified{false};
+    // INFO doesn't work from inside the lambda body. :/
+    JA4::make_JA4_fingerprint(TLS_summary, [&verified](std::string_view sv) {
+      if ("0002,000c,0011" == sv) {
+        verified = true;
+      }
+      return sv;
+    });
+    CHECK(verified);
+  }
+
+  SECTION("When we create a JA4 fingerprint, "
+          "then GREASE values in the cipher list should be ignored.")
+  {
+    TLS_summary.add_cipher(0x0a0a);
+    TLS_summary.add_cipher(2);
+    bool verified{false};
+    // INFO doesn't work from inside the lambda body. :/
+    JA4::make_JA4_fingerprint(TLS_summary, [&verified](std::string_view sv) {
+      if ("0002" == sv) {
+        verified = true;
+      }
+      return sv;
+    });
+    CHECK(verified);
+  }
+
+  // All the tests from now on have enough ciphers to ensure a long enough
+  // hash using our default hash (the id function) so that the length of the
+  // JA4 fingerprint will be valid.
+  TLS_summary.add_cipher(1);
+  TLS_summary.add_cipher(2);
+  TLS_summary.add_cipher(3);
+
+  SECTION("When we create a JA4 fingerprint, "
+          "then we should truncate the section b hash to 12 characters.")
+  {
+    CHECK("001,0002,000_" == JA4::make_JA4_fingerprint(TLS_summary, [](std::string_view sv) {
+                               return sv.empty() ? sv : sv.substr(1);
+                             }).substr(11, 13));
+  }
+
+  SECTION("When we create a JA4 fingeprint, "
+          "then index 10 thereof should contain '_'.")
+  {
+    CHECK("_" == call_JA4(TLS_summary).substr(23, 1));
+  }
+
+  SECTION("When we create a JA4 fingerprint, "
+          "then the c section should be passed through the hash function.")
+  {
+    TLS_summary.add_extension(10);
+    CHECK("111b" == JA4::make_JA4_fingerprint(TLS_summary, [](std::string_view sv) { return inc(sv); }).substr(24, 4));
+  }
+
+  // As per the spec, we expect 4-character, comma-delimited hex values.
+  SECTION("Given only extensions 2, 12, and 17 in that order, "
+          "when we create a JA4 fingerprint, "
+          "then the hash should be invoked with \"0002,000c,0011\".")
+  {
+    TLS_summary.add_extension(2);
+    TLS_summary.add_extension(12);
+    TLS_summary.add_extension(17);
+
+    bool verified{false};
+    // INFO doesn't work from inside the lambda body. :/
+    JA4::make_JA4_fingerprint(TLS_summary, [&verified](std::string_view sv) {
+      if ("0002,000c,0011" == sv) {
+        verified = true;
+      }
+      return sv;
+    });
+    CHECK(verified);
+  }
+
+  SECTION("When we create a JA4 fingerprint, "
+          "then the extension values should be sorted before hashing.")
+  {
+    TLS_summary.add_extension(17);
+    TLS_summary.add_extension(2);
+    TLS_summary.add_extension(12);
+    bool verified{false};
+    // INFO doesn't work from inside the lambda body. :/
+    JA4::make_JA4_fingerprint(TLS_summary, [&verified](std::string_view sv) {
+      if ("0002,000c,0011" == sv) {
+        verified = true;
+      }
+      return sv;
+    });
+    CHECK(verified);
+  }
+
+  SECTION("When we create a JA4 fingerprint, "
+          "then we ignore GREASE, SNI, ALPN, and SNI values in the extensions.")
+  {
+    TLS_summary.add_extension(0x0a0a);
+    TLS_summary.add_extension(0x0);
+    TLS_summary.add_extension(0x10);
+    TLS_summary.add_extension(5);
+    bool verified{false};
+    // INFO doesn't work from inside the lambda body. :/
+    JA4::make_JA4_fingerprint(TLS_summary, [&verified](std::string_view sv) {
+      if ("0005" == sv) {
+        verified = true;
+      }
+      return sv;
+    });
+    CHECK(verified);
+  }
+
+  SECTION("When we create a JA4 fingerprint, "
+          "then we total length of the fingerprint should be 36 characters.")
+  {
+    TLS_summary.add_extension(1);
+    TLS_summary.add_extension(2);
+    TLS_summary.add_extension(3);
+    CHECK(36 == call_JA4(TLS_summary).size());
+  }
+}
+
+std::string
+call_JA4(JA4::TLSClientHelloSummary const &TLS_summary)
+{
+  return JA4::make_JA4_fingerprint(TLS_summary, [](std::string_view sv) { return sv; });
+}
+
+std::string
+inc(std::string_view sv)
+{
+  std::string result;
+  result.resize(sv.size());
+  std::transform(sv.begin(), sv.end(), result.begin(), [](char c) { return c + 1; });
+  return result;
+}
diff --git a/plugins/experimental/jax_fingerprint/ja4/tls_client_hello_summary.cc b/plugins/experimental/jax_fingerprint/ja4/tls_client_hello_summary.cc
new file mode 100644
index 0000000000..cc35a1c83b
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4/tls_client_hello_summary.cc
@@ -0,0 +1,112 @@
+/** @file
+ *
+  TLSClientHelloSummary data structure for JA4 fingerprint calculation.
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+
+ */
+
+#include "ja4.h"
+
+#include <algorithm>
+#include <array>
+#include <cstdint>
+#include <functional>
+#include <vector>
+
+namespace
+{
+
+constexpr std::array<std::uint16_t, 16> GREASE_values{0x0a0a, 0x1a1a, 0x2a2a, 0x3a3a, 0x4a4a, 0x5a5a, 0x6a6a, 0x7a7a,
+                                                      0x8a8a, 0x9a9a, 0xaaaa, 0xbaba, 0xcaca, 0xdada, 0xeaea, 0xfafa};
+constexpr std::uint16_t                 extension_SNI{0x0};
+constexpr std::uint16_t                 extension_ALPN{0x10};
+
+} // end anonymous namespace
+
+static bool is_ignored_non_GREASE_extension(std::uint16_t extension);
+
+std::vector<std::uint16_t> const &
+JA4::TLSClientHelloSummary::get_ciphers() const
+{
+  return this->_ciphers;
+}
+
+void
+JA4::TLSClientHelloSummary::add_cipher(std::uint16_t cipher)
+{
+  if (is_GREASE(cipher)) {
+    return;
+  }
+
+  this->_ciphers.push_back(cipher);
+}
+
+std::vector<std::uint16_t> const &
+JA4::TLSClientHelloSummary::get_extensions() const
+{
+  return this->_extensions;
+}
+
+void
+JA4::TLSClientHelloSummary::add_extension(std::uint16_t extension)
+{
+  if (is_GREASE(extension)) {
+    return;
+  }
+
+  if (extension_SNI == extension) {
+    this->_SNI_type = SNI::to_domain;
+  }
+
+  ++this->_extension_count_including_sni_and_alpn;
+  if (!is_ignored_non_GREASE_extension(extension)) {
+    this->_extensions.push_back(extension);
+  }
+}
+
+JA4::TLSClientHelloSummary::difference_type
+JA4::TLSClientHelloSummary::get_cipher_count() const
+{
+  return this->_ciphers.size();
+}
+
+JA4::TLSClientHelloSummary::difference_type
+JA4::TLSClientHelloSummary::get_extension_count() const
+{
+  return this->_extension_count_including_sni_and_alpn;
+}
+
+bool
+is_ignored_non_GREASE_extension(std::uint16_t extension)
+{
+  return (extension_SNI == extension) || (extension_ALPN == extension);
+}
+
+JA4::SNI
+JA4::TLSClientHelloSummary::get_SNI_type() const
+{
+  return this->_SNI_type;
+}
+
+bool
+JA4::is_GREASE(std::uint16_t value)
+{
+  return std::binary_search(GREASE_values.begin(), GREASE_values.end(), value);
+}
diff --git a/plugins/experimental/jax_fingerprint/ja4h/datasource.cc b/plugins/experimental/jax_fingerprint/ja4h/datasource.cc
new file mode 100644
index 0000000000..dcddfa4255
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4h/datasource.cc
@@ -0,0 +1,44 @@
+/** @file
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+ */
+
+#include "datasource.h"
+
+#include <cctype>
+#include <algorithm>
+
+bool
+Datasource::_should_include_field(std::string_view name)
+{
+  constexpr std::string_view COOKIE{"cookie"};
+  constexpr std::string_view REFERER{"referer"};
+
+  if (name.length() == COOKIE.length()) {
+    if (std::equal(name.begin(), name.end(), COOKIE.begin(), [](char c1, char c2) { return std::tolower(c1) == c2; })) {
+      return false;
+    }
+  } else if (name.length() == REFERER.length()) {
+    if (std::equal(name.begin(), name.end(), REFERER.begin(), [](char c1, char c2) { return std::tolower(c1) == c2; })) {
+      return false;
+    }
+  }
+
+  return true;
+}
diff --git a/plugins/experimental/jax_fingerprint/ja4h/datasource.h b/plugins/experimental/jax_fingerprint/ja4h/datasource.h
new file mode 100644
index 0000000000..3833410567
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4h/datasource.h
@@ -0,0 +1,42 @@
+/** @file
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+
+ */
+
+#pragma once
+
+#include <string_view>
+
+class Datasource
+{
+public:
+  Datasource()                                                     = default;
+  virtual ~Datasource()                                            = default;
+  virtual std::string_view get_method()                            = 0;
+  virtual int              get_version()                           = 0;
+  virtual bool             has_cookie_field()                      = 0;
+  virtual bool             has_referer_field()                     = 0;
+  virtual int              get_field_count()                       = 0;
+  virtual std::string_view get_accept_language()                   = 0;
+  virtual void             get_headers_hash(unsigned char out[32]) = 0;
+
+protected:
+  bool _should_include_field(std::string_view name);
+};
diff --git a/plugins/experimental/jax_fingerprint/ja4h/ja4h.cc b/plugins/experimental/jax_fingerprint/ja4h/ja4h.cc
new file mode 100644
index 0000000000..7175797035
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4h/ja4h.cc
@@ -0,0 +1,118 @@
+/** @file
+ *
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+
+ */
+
+#include "ja4h.h"
+#include <openssl/sha.h>
+#include <algorithm>
+#include <cctype>
+
+static void
+generate_ja4h_a(char *out, Datasource &datasource)
+{
+  std::string_view method = datasource.get_method();
+  if (method.length() >= 2) {
+    out[0] = std::tolower(method[0]);
+    out[1] = std::tolower(method[1]);
+  } else {
+    // This case seems to be undefined on the spec
+    out[0] = 'x';
+    out[1] = 'x';
+  }
+  int version     = datasource.get_version();
+  out[2]          = 0x30 | (version >> 16);
+  out[3]          = 0x30 | (version & 0xFFFF);
+  out[4]          = datasource.has_cookie_field() ? 'c' : 'n';
+  out[5]          = datasource.has_referer_field() ? 'r' : 'n';
+  int field_count = datasource.get_field_count();
+  if (field_count < 100) {
+    out[6] = 0x30 | (field_count / 10);
+    out[7] = 0x30 | (field_count % 10);
+  } else {
+    out[6] = '9';
+    out[7] = '9';
+  }
+  std::string_view accept_lang = datasource.get_accept_language();
+  if (accept_lang.empty()) {
+    out[8]  = '0';
+    out[9]  = '0';
+    out[10] = '0';
+    out[11] = '0';
+  } else {
+    for (int i = 0, j = 0; i < 4; ++i) {
+      while (static_cast<size_t>(j) < accept_lang.size() && accept_lang[j] < 'A' && accept_lang[j] != ';') {
+        ++j;
+      }
+      if (static_cast<size_t>(j) == accept_lang.size() || accept_lang[j] == ';') {
+        out[8 + i] = '0';
+      } else {
+        out[8 + i] = std::tolower(accept_lang[j]);
+        ++j;
+      }
+    }
+  }
+}
+
+static void
+generate_ja4h_b(char *out, Datasource &datasource)
+{
+  unsigned char hash[32];
+
+  datasource.get_headers_hash(hash);
+
+  for (int i = 0; i < 6; ++i) {
+    unsigned int h = hash[i] >> 4;
+    unsigned int l = hash[i] & 0x0F;
+    out[i * 2]     = h <= 9 ? ('0' + h) : ('a' + h - 10);
+    out[i * 2 + 1] = l <= 9 ? ('0' + l) : ('a' + l - 10);
+  }
+}
+
+static void
+generate_ja4h_c(char *out, Datasource & /* datasource ATS_UNUSED */)
+{
+  // Not implemented
+  for (int i = 0; i < 12; ++i) {
+    out[i] = '0';
+  }
+}
+
+static void
+generate_ja4h_d(char *out, Datasource & /* datasource ATS_UNUSED */)
+{
+  // Not implemented
+  for (int i = 0; i < 12; ++i) {
+    out[i] = '0';
+  }
+}
+
+void
+generate_ja4h_fingerprint(char *out, Datasource &datasource)
+{
+  generate_ja4h_a(&(out[PART_A_POSITION]), datasource);
+  out[DELIMITER_1_POSITION] = DELIMITER;
+  generate_ja4h_b(&(out[PART_B_POSITION]), datasource);
+  out[DELIMITER_2_POSITION] = DELIMITER;
+  generate_ja4h_c(&(out[PART_C_POSITION]), datasource);
+  out[DELIMITER_3_POSITION] = DELIMITER;
+  generate_ja4h_d(&(out[PART_D_POSITION]), datasource);
+}
diff --git a/plugins/experimental/jax_fingerprint/ja4h/ja4h.h b/plugins/experimental/jax_fingerprint/ja4h/ja4h.h
new file mode 100644
index 0000000000..a49aa62c91
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4h/ja4h.h
@@ -0,0 +1,41 @@
+/** @file
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+
+ */
+
+#pragma once
+
+#include "datasource.h"
+
+constexpr int    FINGERPRINT_LENGTH   = 51;
+constexpr size_t PART_A_POSITION      = 0;
+constexpr size_t PART_B_POSITION      = 13;
+constexpr size_t PART_C_POSITION      = 26;
+constexpr size_t PART_D_POSITION      = 39;
+constexpr size_t PART_A_LENGTH        = 12;
+constexpr size_t PART_B_LENGTH        = 12;
+constexpr size_t PART_C_LENGTH        = 12;
+constexpr size_t PART_D_LENGTH        = 12;
+constexpr char   DELIMITER            = '-';
+constexpr size_t DELIMITER_1_POSITION = 12;
+constexpr size_t DELIMITER_2_POSITION = 25;
+constexpr size_t DELIMITER_3_POSITION = 38;
+
+void generate_ja4h_fingerprint(char *out, Datasource &datasource);
diff --git a/plugins/experimental/jax_fingerprint/ja4h/ja4h_method.cc b/plugins/experimental/jax_fingerprint/ja4h/ja4h_method.cc
new file mode 100644
index 0000000000..c559b7cfbd
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4h/ja4h_method.cc
@@ -0,0 +1,173 @@
+/** @file
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+ */
+
+#include "ts/ts.h"
+
+#include <plugin.h>
+#include <context.h>
+#include "ja4h_method.h"
+#include "ja4h.h"
+#include "datasource.h"
+
+#include "openssl/sha.h"
+
+namespace ja4h_method
+{
+void on_request(JAxContext *, TSHttpTxn);
+
+struct Method method = {
+  "JA4H",
+  Method::Type::REQUEST_BASED,
+  nullptr,
+  on_request,
+};
+
+class TxnDatasource : public Datasource
+{
+public:
+  TxnDatasource(TSHttpTxn txnp);
+  ~TxnDatasource();
+  std::string_view get_method() override;
+  int              get_version() override;
+  bool             has_cookie_field() override;
+  bool             has_referer_field() override;
+  int              get_field_count() override;
+  std::string_view get_accept_language() override;
+  void             get_headers_hash(unsigned char out[32]) override;
+
+private:
+  TSHttpTxn _txn;
+  TSMBuffer _request = nullptr;
+  TSMLoc    _req_hdr = nullptr;
+};
+
+TxnDatasource::TxnDatasource(TSHttpTxn txnp) : _txn(txnp)
+{
+  TSHttpTxnClientReqGet(txnp, &(this->_request), &(this->_req_hdr));
+}
+
+TxnDatasource::~TxnDatasource()
+{
+  if (this->_request != nullptr) {
+    TSHandleMLocRelease(this->_request, TS_NULL_MLOC, this->_req_hdr);
+  }
+}
+
+std::string_view
+TxnDatasource::get_method()
+{
+  if (this->_request == nullptr) {
+    return "";
+  }
+
+  int         method_len;
+  const char *method = TSHttpHdrMethodGet(this->_request, this->_req_hdr, &method_len);
+
+  return {method, static_cast<size_t>(method_len)};
+}
+
+int
+TxnDatasource::get_version()
+{
+  if (TSHttpTxnClientProtocolStackContains(this->_txn, "h2")) {
+    return 2 << 16;
+  } else if (TSHttpTxnClientProtocolStackContains(this->_txn, "h3")) {
+    return 3 << 16;
+  } else {
+    return TSHttpHdrVersionGet(this->_request, this->_req_hdr);
+  }
+}
+
+bool
+TxnDatasource::has_cookie_field()
+{
+  TSMLoc mloc = TSMimeHdrFieldFind(this->_request, this->_req_hdr, TS_MIME_FIELD_COOKIE, TS_MIME_LEN_COOKIE);
+  if (mloc) {
+    TSHandleMLocRelease(this->_request, this->_req_hdr, mloc);
+  }
+  return mloc != TS_NULL_MLOC;
+}
+
+bool
+TxnDatasource::has_referer_field()
+{
+  TSMLoc mloc = TSMimeHdrFieldFind(this->_request, this->_req_hdr, TS_MIME_FIELD_REFERER, TS_MIME_LEN_REFERER);
+  if (mloc) {
+    TSHandleMLocRelease(this->_request, this->_req_hdr, mloc);
+  }
+  return mloc != TS_NULL_MLOC;
+}
+
+int
+TxnDatasource::get_field_count()
+{
+  return TSMimeHdrFieldsCount(this->_request, this->_req_hdr);
+}
+
+std::string_view
+TxnDatasource::get_accept_language()
+{
+  TSMLoc mloc = TSMimeHdrFieldFind(this->_request, this->_req_hdr, TS_MIME_FIELD_ACCEPT_LANGUAGE, TS_MIME_LEN_ACCEPT_LANGUAGE);
+  if (mloc == TS_NULL_MLOC) {
+    return {};
+  }
+  int         value_len;
+  const char *value = TSMimeHdrFieldValueStringGet(this->_request, this->_req_hdr, mloc, 0, &value_len);
+  TSHandleMLocRelease(this->_request, this->_req_hdr, mloc);
+  return {value, static_cast<size_t>(value_len)};
+}
+
+void
+TxnDatasource::get_headers_hash(unsigned char out[32])
+{
+  SHA256_CTX sha256ctx;
+  SHA256_Init(&sha256ctx);
+
+  TSMLoc field_loc = TSMimeHdrFieldGet(this->_request, this->_req_hdr, 0);
+
+  while (field_loc != TS_NULL_MLOC) {
+    int   field_name_len;
+    char *field_name = const_cast<char *>(TSMimeHdrFieldNameGet(this->_request, this->_req_hdr, field_loc, &field_name_len));
+
+    if (this->_should_include_field({field_name, static_cast<size_t>(field_name_len)})) {
+      SHA256_Update(&sha256ctx, field_name, field_name_len);
+    }
+
+    TSMLoc next_field_loc = TSMimeHdrFieldNext(this->_request, this->_req_hdr, field_loc);
+    TSHandleMLocRelease(this->_request, this->_req_hdr, field_loc);
+    field_loc = next_field_loc;
+  }
+
+  SHA256_Final(out, &sha256ctx);
+}
+
+} // namespace ja4h_method
+
+void
+ja4h_method::on_request(JAxContext *ctx, TSHttpTxn txnp)
+{
+  char          fingerprint[FINGERPRINT_LENGTH];
+  TxnDatasource datasource{txnp};
+
+  generate_ja4h_fingerprint(fingerprint, datasource);
+
+  ctx->set_fingerprint({fingerprint, FINGERPRINT_LENGTH});
+}
diff --git a/plugins/experimental/jax_fingerprint/ja4h/ja4h_method.h b/plugins/experimental/jax_fingerprint/ja4h/ja4h_method.h
new file mode 100644
index 0000000000..e64901b815
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4h/ja4h_method.h
@@ -0,0 +1,32 @@
+/** @file
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+
+ */
+
+#pragma once
+
+#include <method.h>
+
+namespace ja4h_method
+{
+
+extern struct Method method;
+
+}
diff --git a/plugins/experimental/jax_fingerprint/ja4h/test_ja4h.cc b/plugins/experimental/jax_fingerprint/ja4h/test_ja4h.cc
new file mode 100644
index 0000000000..2ae5ef8878
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4h/test_ja4h.cc
@@ -0,0 +1,168 @@
+/** @file
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+ */
+
+#include "ja4h.h"
+#include "datasource.h"
+
+#include <openssl/sha.h>
+#include <catch2/catch_test_macros.hpp>
+
+#include <map>
+
+class MockDatasource : public Datasource
+{
+public:
+  MockDatasource() {}
+  ~MockDatasource() {}
+
+  std::string_view
+  get_method() override
+  {
+    return this->_method;
+  }
+  int
+  get_version() override
+  {
+    return this->_version;
+  }
+  bool
+  has_cookie_field() override
+  {
+    return this->_fields.contains("Cookie");
+  }
+  bool
+  has_referer_field() override
+  {
+    return this->_fields.contains("Referer");
+  }
+  int
+  get_field_count() override
+  {
+    return this->_fields.size();
+  }
+  std::string_view
+  get_accept_language() override
+  {
+    if (auto ite = this->_fields.find("Accept-Language"); ite != this->_fields.end()) {
+      return ite->second;
+    } else {
+      return {};
+    }
+  }
+  void
+  get_headers_hash(unsigned char out[32]) override
+  {
+    SHA256_CTX sha256ctx;
+    SHA256_Init(&sha256ctx);
+
+    for (auto &&ite : this->_fields) {
+      if (this->_should_include_field({ite.first.c_str(), ite.first.size()})) {
+        SHA256_Update(&sha256ctx, ite.first.c_str(), ite.first.size());
+      }
+    }
+
+    SHA256_Final(out, &sha256ctx);
+  }
+
+  void
+  set_method(std::string method)
+  {
+    this->_method = method;
+  }
+  void
+  set_version(int version)
+  {
+    this->_version = version;
+  }
+  void
+  set_fields(std::map<std::string, std::string> fields)
+  {
+    this->_fields = fields;
+  }
+
+private:
+  std::string                        _method;
+  int                                _version;
+  std::map<std::string, std::string> _fields;
+};
+
+std::string_view
+SHA256_12(std::string_view in)
+{
+  uint8_t hash[32];
+  SHA256(reinterpret_cast<const uint8_t *>(in.data()), in.size(), hash);
+
+  static char out[12];
+  for (int i = 0; i < 6; ++i) {
+    uint8_t h      = hash[i] >> 4;
+    uint8_t l      = hash[i] & 0x0F;
+    out[i * 2]     = h <= 9 ? '0' + h : 'a' + h - 10;
+    out[i * 2 + 1] = l <= 9 ? '0' + l : 'a' + l - 10;
+  }
+  return {out, sizeof(out)};
+}
+
+TEST_CASE("JA4H")
+{
+  MockDatasource datasource;
+  char           fingerprint[FINGERPRINT_LENGTH];
+
+  SECTION("HTTP/1.0 GET with Host")
+  {
+    datasource.set_method("GET");
+    datasource.set_version(1 << 16 | 0);
+    datasource.set_fields({
+      {"Host", "abc.example"},
+    });
+
+    generate_ja4h_fingerprint(fingerprint, datasource);
+
+    std::string_view fingerprint_sv{fingerprint, sizeof(fingerprint)};
+    CHECK(fingerprint_sv.substr(PART_A_POSITION, PART_A_LENGTH) == "ge10nn010000");
+    CHECK(fingerprint_sv[DELIMITER_1_POSITION] == DELIMITER);
+    CHECK(fingerprint_sv.substr(PART_B_POSITION, PART_B_LENGTH) == SHA256_12("Host"));
+    CHECK(fingerprint_sv[DELIMITER_2_POSITION] == DELIMITER);
+    CHECK(fingerprint_sv.substr(PART_C_POSITION, PART_C_LENGTH) == "000000000000");
+    CHECK(fingerprint_sv[DELIMITER_3_POSITION] == DELIMITER);
+    CHECK(fingerprint_sv.substr(PART_D_POSITION, PART_D_LENGTH) == "000000000000");
+  }
+
+  SECTION("HTTP/1.1 POST with Accept-Language and Referer")
+  {
+    datasource.set_method("POST");
+    datasource.set_version(1 << 16 | 1);
+    datasource.set_fields({
+      {"Accept-Language", "en"                     },
+      {"Referer",         "https://xyz.example/foo"},
+    });
+
+    generate_ja4h_fingerprint(fingerprint, datasource);
+
+    std::string_view fingerprint_sv{fingerprint, sizeof(fingerprint)};
+    CHECK(fingerprint_sv.substr(PART_A_POSITION, PART_A_LENGTH) == "po11nr02en00");
+    CHECK(fingerprint_sv[DELIMITER_1_POSITION] == DELIMITER);
+    CHECK(fingerprint_sv.substr(PART_B_POSITION, PART_B_LENGTH) == SHA256_12("Accept-Language"));
+    CHECK(fingerprint_sv[DELIMITER_2_POSITION] == DELIMITER);
+    CHECK(fingerprint_sv.substr(PART_C_POSITION, PART_C_LENGTH) == "000000000000");
+    CHECK(fingerprint_sv[DELIMITER_3_POSITION] == DELIMITER);
+    CHECK(fingerprint_sv.substr(PART_D_POSITION, PART_D_LENGTH) == "000000000000");
+  }
+}
diff --git a/plugins/experimental/jax_fingerprint/log.cc b/plugins/experimental/jax_fingerprint/log.cc
new file mode 100644
index 0000000000..e18ad3d49c
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/log.cc
@@ -0,0 +1,52 @@
+/** @file
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+ */
+
+#include "plugin.h"
+#include "log.h"
+
+#include "ts/ts.h"
+
+#include <string>
+
+bool
+create_log_file(const std::string &filename, TSTextLogObject &log_handle)
+{
+  return (TS_SUCCESS == TSTextLogObjectCreate(filename.c_str(), TS_LOG_MODE_ADD_TIMESTAMP, &log_handle));
+}
+
+void
+log_fingerprint(const JAxContext *ctx, TSTextLogObject &log_handle)
+{
+  if (log_handle == nullptr) {
+    Dbg(dbg_ctl, "Log handle is not initialized.");
+    return;
+  }
+  if (TS_ERROR == TSTextLogObjectWrite(log_handle, "Client: %s\t%s: %s", ctx->get_addr(), ctx->get_method_name(),
+                                       ctx->get_fingerprint().c_str())) {
+    Dbg(dbg_ctl, "Failed to write to log!");
+  }
+}
+
+void
+flush_log_file(TSTextLogObject &log_handle)
+{
+  TSTextLogObjectDestroy(log_handle);
+}
diff --git a/plugins/experimental/jax_fingerprint/log.h b/plugins/experimental/jax_fingerprint/log.h
new file mode 100644
index 0000000000..8dad8fa2be
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/log.h
@@ -0,0 +1,30 @@
+/** @file
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+
+ */
+
+#pragma once
+
+#include "ts/ts.h"
+#include "context.h"
+
+bool create_log_file(const std::string &filename, TSTextLogObject &log_handle);
+void log_fingerprint(const JAxContext *ctx, TSTextLogObject &log_handle);
+void flush_log_file(TSTextLogObject &log_handle);
diff --git a/plugins/experimental/jax_fingerprint/method.h b/plugins/experimental/jax_fingerprint/method.h
new file mode 100644
index 0000000000..710d391849
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/method.h
@@ -0,0 +1,68 @@
+/** @file
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+
+ */
+
+#pragma once
+
+#include "ts/ts.h"
+
+#include "context.h"
+#include <string_view>
+
+/** @brief Handler invoked on TLS ClientHello for connection-based fingerprinting. */
+using ClientHelloHandler = void (*)(JAxContext *, TSVConn);
+
+/** @brief Handler invoked on HTTP request read for request-based fingerprinting. */
+using RequestReadHandler = void (*)(JAxContext *, TSHttpTxn);
+
+/**
+ * @brief Describes a fingerprinting method (e.g. JA3, JA4, JA4H).
+ *
+ * Each method has a type that determines when it runs:
+ *  - @c CONNECTION_BASED methods fire on the TLS ClientHello via @c on_client_hello.
+ *  - @c REQUEST_BASED methods fire on the HTTP request via @c on_request.
+ *
+ * Concrete method instances are defined in per-method directories
+ * (ja3/, ja4/, ja4h/) and selected by the plugin configuration.
+ */
+struct Method {
+  enum class Type {
+    /** Fingerprint derived from TLS ClientHello. */
+    CONNECTION_BASED,
+    /** Fingerprint derived from the HTTP request. */
+    REQUEST_BASED,
+  };
+
+  /**
+   * Human-readable method name (e.g. "ja3").
+   * Must reference a null-terminated string.
+   */
+  std::string_view name;
+
+  /** When this method runs. */
+  Type type;
+
+  /** Callback for CONNECTION_BASED methods (may be nullptr). */
+  ClientHelloHandler on_client_hello;
+
+  /** Callback for REQUEST_BASED methods (may be nullptr). */
+  RequestReadHandler on_request;
+};
diff --git a/plugins/experimental/jax_fingerprint/plugin.cc b/plugins/experimental/jax_fingerprint/plugin.cc
new file mode 100644
index 0000000000..e719836b6d
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/plugin.cc
@@ -0,0 +1,480 @@
+/** @file
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+ */
+
+#include "plugin.h"
+#include "config.h"
+#include "context.h"
+#include "userarg.h"
+#include "method.h"
+#include "header.h"
+#include "log.h"
+
+#include "ja4/ja4_method.h"
+#include "ja4h/ja4h_method.h"
+#include "ja3/ja3_method.h"
+
+#include <ts/apidefs.h>
+#include <ts/ts.h>
+#include <ts/remap.h>
+#include <ts/remap_version.h>
+
+#include <getopt.h>
+
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <cstring>
+#include <memory>
+#include <string>
+#include <string_view>
+#include <version>
+
+DbgCtl dbg_ctl{PLUGIN_NAME};
+
+namespace
+{
+
+} // end anonymous namespace
+
+static bool
+read_config_option(int argc, char const *argv[], PluginConfig &config)
+{
+  const struct option longopts[] = {
+    {"standalone",   no_argument,       nullptr, 's'},
+    {"method",       required_argument, nullptr, 'M'}, // JA4, JA4H, or JA3
+    {"mode",         required_argument, nullptr, 'm'}, // overwrite, keep, or append
+    {"header",       required_argument, nullptr, 'h'},
+    {"via-header",   required_argument, nullptr, 'v'},
+    {"log-filename", required_argument, nullptr, 'f'},
+    {"servernames",  required_argument, nullptr, 'S'},
+    {nullptr,        0,                 nullptr, 0  }
+  };
+
+  optind = 0;
+  int opt{0};
+  while ((opt = getopt_long(argc, const_cast<char *const *>(argv), "", longopts, nullptr)) >= 0) {
+    switch (opt) {
+    case '?':
+      Dbg(dbg_ctl, "Unrecognized command argument.");
+      break;
+    case 'M':
+      if (strcmp("JA4", optarg) == 0) {
+        config.method = ja4_method::method;
+      } else if (strcmp("JA4H", optarg) == 0) {
+        config.method = ja4h_method::method;
+      } else if (strcmp("JA3", optarg) == 0) {
+        config.method = ja3_method::method;
+      } else {
+        Dbg(dbg_ctl, "Unexpected method: %s", optarg);
+        return false;
+      }
+      break;
+    case 'm':
+      if (strcmp("overwrite", optarg) == 0) {
+        config.mode = Mode::OVERWRITE;
+      } else if (strcmp("keep", optarg) == 0) {
+        config.mode = Mode::KEEP;
+      } else if (strcmp("append", optarg) == 0) {
+        config.mode = Mode::APPEND;
+      } else {
+        Dbg(dbg_ctl, "Unexpected mode: %s", optarg);
+        return false;
+      }
+      break;
+    case 'h':
+      config.header_name = {optarg, strlen(optarg)};
+      break;
+    case 'v':
+      config.via_header_name = {optarg, strlen(optarg)};
+      break;
+    case 'f':
+      config.log_filename = {optarg, strlen(optarg)};
+      break;
+    case 's':
+      config.standalone = true;
+      break;
+    case 'S':
+      for (std::string_view input(optarg, strlen(optarg)); !input.empty();) {
+        auto pos = input.find(',');
+        config.servernames.emplace(input.substr(0, pos));
+        input.remove_prefix(pos == std::string_view::npos ? input.size() : pos + 1);
+      }
+      break;
+    case 0:
+    case -1:
+      break;
+    default:
+      Dbg(dbg_ctl, "Unexpected options error.");
+      return false;
+    }
+  }
+
+  if (config.method.name == "uninitialized") {
+    TSError("[%s] Method must be specified", PLUGIN_NAME);
+    return false;
+  }
+
+  Dbg(dbg_ctl, "JAx method is %s", config.method.name.data());
+  Dbg(dbg_ctl, "JAx mode is %d", static_cast<int>(config.mode));
+  Dbg(dbg_ctl, "JAx header is %s", !config.header_name.empty() ? config.header_name.c_str() : "DISABLED");
+  Dbg(dbg_ctl, "JAx via-header is %s", !config.via_header_name.empty() ? config.via_header_name.c_str() : "DISABLED");
+  Dbg(dbg_ctl, "JAx log file is %s", !config.log_filename.empty() ? config.log_filename.c_str() : "DISABLED");
+  Dbg(dbg_ctl, "JAx standalone mode  is %s", config.standalone ? "ENABLED" : "DISABLED");
+  for (auto &&servername : config.servernames) {
+    Dbg(dbg_ctl, "%s", servername.c_str());
+  }
+
+  return true;
+}
+
+void
+modify_headers(JAxContext *ctx, TSHttpTxn txnp, PluginConfig &config)
+{
+  if (!ctx->get_fingerprint().empty()) {
+    switch (config.mode) {
+    case Mode::KEEP:
+      if (!config.header_name.empty() && !has_header(txnp, config.header_name)) {
+        set_header(txnp, config.header_name, ctx->get_fingerprint());
+      }
+      if (!config.via_header_name.empty() && !has_header(txnp, config.via_header_name)) {
+        set_via_header(txnp, config.via_header_name);
+      }
+      break;
+    case Mode::OVERWRITE:
+      if (!config.header_name.empty()) {
+        set_header(txnp, config.header_name, ctx->get_fingerprint());
+      }
+      if (!config.via_header_name.empty()) {
+        set_via_header(txnp, config.via_header_name);
+      }
+      break;
+    case Mode::APPEND:
+      if (!config.header_name.empty()) {
+        append_header(txnp, config.header_name, ctx->get_fingerprint());
+      }
+      if (!config.via_header_name.empty()) {
+        append_via_header(txnp, config.via_header_name);
+      }
+      break;
+    default:
+      break;
+    }
+  } else {
+    Dbg(dbg_ctl, "No fingerprint attached to vconn!");
+    if (config.mode == Mode::OVERWRITE) {
+      if (!config.header_name.empty()) {
+        remove_header(txnp, config.header_name);
+      }
+      if (!config.via_header_name.empty()) {
+        remove_header(txnp, config.via_header_name);
+      }
+    }
+  }
+}
+
+int
+handle_client_hello(void *edata, PluginConfig &config)
+{
+  TSVConn     vconn = static_cast<TSVConn>(edata);
+  JAxContext *ctx   = get_user_arg(vconn, config);
+
+  if (!config.servernames.empty()) {
+    const char *servername;
+    int         servername_len;
+    servername = TSVConnSslSniGet(vconn, &servername_len);
+    if (servername != nullptr && servername_len > 0) {
+#ifdef __cpp_lib_generic_unordered_lookup
+      if (!config.servernames.contains(std::string_view(servername, servername_len))) {
+#else
+      if (!config.servernames.contains({servername, static_cast<size_t>(servername_len)})) {
+#endif
+        Dbg(dbg_ctl, "Server name %.*s is not in the server name set", servername_len, servername);
+        TSVConnReenable(vconn);
+        return TS_SUCCESS;
+      }
+    } else {
+      Dbg(dbg_ctl, "No SNI present but server name filtering is configured; skipping fingerprint generation");
+      TSVConnReenable(vconn);
+      return TS_SUCCESS;
+    }
+  }
+
+  if (nullptr == ctx) {
+    ctx = new JAxContext(config.method.name.data(), TSNetVConnRemoteAddrGet(vconn));
+    set_user_arg(vconn, config, ctx);
+  }
+
+  if (config.method.on_client_hello) {
+    config.method.on_client_hello(ctx, vconn);
+  }
+
+  TSVConnReenable(vconn);
+
+  return TS_SUCCESS;
+}
+
+int
+handle_read_request_hdr(void *edata, PluginConfig &config)
+{
+  TSHttpTxn txnp = static_cast<TSHttpTxn>(edata);
+  TSHttpSsn ssnp = TSHttpTxnSsnGet(txnp);
+  if (ssnp == nullptr) {
+    Dbg(dbg_ctl, "Failed to get ssn object.");
+    return TS_SUCCESS;
+  }
+
+  TSVConn vconn = TSHttpSsnClientVConnGet(ssnp);
+  if (vconn == nullptr) {
+    Dbg(dbg_ctl, "Failed to get vconn object.");
+    return TS_SUCCESS;
+  }
+
+  void *container;
+  if (config.method.type == Method::Type::CONNECTION_BASED) {
+    container = vconn;
+  } else {
+    container = txnp;
+  }
+  JAxContext *ctx = get_user_arg(container, config);
+  if (nullptr == ctx) {
+    if (container == vconn) {
+      // JAxContext should be created on client hello hook
+      Dbg(dbg_ctl, "No context. Skipping.");
+      return TS_SUCCESS;
+    }
+    ctx = new JAxContext(config.method.name.data(), TSNetVConnRemoteAddrGet(vconn));
+    set_user_arg(container, config, ctx);
+  }
+
+  if (config.method.on_request) {
+    config.method.on_request(ctx, txnp);
+  }
+
+  if (!config.log_filename.empty()) {
+    log_fingerprint(ctx, config.log_handle);
+  }
+
+  modify_headers(ctx, txnp, config);
+
+  return TS_SUCCESS;
+}
+
+int
+handle_http_txn_close(void *edata, PluginConfig &config)
+{
+  TSHttpTxn txnp = static_cast<TSHttpTxn>(edata);
+
+  delete get_user_arg(txnp, config);
+  set_user_arg(txnp, config, nullptr);
+
+  TSHttpTxnReenable(txnp, TS_EVENT_HTTP_CONTINUE);
+  return TS_SUCCESS;
+}
+
+int
+handle_vconn_close(void *edata, PluginConfig &config)
+{
+  TSVConn vconn = static_cast<TSVConn>(edata);
+
+  delete get_user_arg(vconn, config);
+  set_user_arg(vconn, config, nullptr);
+
+  TSVConnReenable(vconn);
+  return TS_SUCCESS;
+}
+
+int
+main_handler(TSCont cont, TSEvent event, void *edata)
+{
+  int ret;
+
+  auto config = static_cast<PluginConfig *>(TSContDataGet(cont));
+  if (config == nullptr) {
+    // Null config means this continuation is no longer needed.
+    if (event == TS_EVENT_SSL_CLIENT_HELLO || event == TS_EVENT_VCONN_CLOSE) {
+      TSVConnReenable(static_cast<TSVConn>(edata));
+    } else {
+      TSHttpTxnReenable(static_cast<TSHttpTxn>(edata), TS_EVENT_HTTP_CONTINUE);
+    }
+    return TS_SUCCESS;
+  }
+
+  switch (event) {
+  case TS_EVENT_SSL_CLIENT_HELLO:
+    ret = handle_client_hello(edata, *config);
+    break;
+  case TS_EVENT_HTTP_READ_REQUEST_HDR:
+    ret = handle_read_request_hdr(edata, *config);
+    TSHttpTxnReenable(static_cast<TSHttpTxn>(edata), TS_EVENT_HTTP_CONTINUE);
+    break;
+  case TS_EVENT_HTTP_TXN_CLOSE:
+    ret = handle_http_txn_close(edata, *config);
+    break;
+  case TS_EVENT_VCONN_CLOSE:
+    ret = handle_vconn_close(edata, *config);
+    break;
+  default:
+    Dbg(dbg_ctl, "Unexpected event %d.", event);
+    // We ignore the event, but we don't want to reject the connection.
+    ret = TS_SUCCESS;
+  }
+
+  return ret;
+}
+
+void
+TSPluginInit(int argc, char const **argv)
+{
+  TSPluginRegistrationInfo info;
+  info.plugin_name   = PLUGIN_NAME;
+  info.vendor_name   = PLUGIN_VENDOR;
+  info.support_email = PLUGIN_SUPPORT_EMAIL;
+
+  if (TS_SUCCESS != TSPluginRegister(&info)) {
+    TSError("[%s] Failed to register.", PLUGIN_NAME);
+    return;
+  }
+
+  PluginConfig *config = new PluginConfig();
+  config->plugin_type  = PluginType::GLOBAL;
+
+  if (!read_config_option(argc, argv, *config)) {
+    TSError("[%s] Failed to parse options.", PLUGIN_NAME);
+    return;
+  }
+
+  if (!config->log_filename.empty()) {
+    if (!create_log_file(config->log_filename, config->log_handle)) {
+      TSError("[%s] Failed to create log.", PLUGIN_NAME);
+      return;
+    } else {
+      Dbg(dbg_ctl, "Created log file.");
+    }
+  }
+
+  if (reserve_user_arg(*config) == TS_ERROR) {
+    TSError("[%s] Failed to reserve user arg index.", PLUGIN_NAME);
+    return;
+  }
+
+  TSCont cont = TSContCreate(main_handler, nullptr);
+  TSContDataSet(cont, config);
+  if (config->method.on_client_hello) {
+    TSHttpHookAdd(TS_SSL_CLIENT_HELLO_HOOK, cont);
+  }
+  if (config->standalone) {
+    TSHttpHookAdd(TS_HTTP_READ_REQUEST_HDR_HOOK, cont);
+  }
+  if (config->method.type == Method::Type::CONNECTION_BASED) {
+    TSHttpHookAdd(TS_VCONN_CLOSE_HOOK, cont);
+  } else {
+    TSHttpHookAdd(TS_HTTP_TXN_CLOSE_HOOK, cont);
+  }
+}
+
+TSReturnCode
+TSRemapInit(TSRemapInterface *api_info, char *errbuf, int errbuf_size)
+{
+  Dbg(dbg_ctl, "JAx Remap Plugin initializing..");
+  CHECK_REMAP_API_COMPATIBILITY(api_info, errbuf, errbuf_size);
+
+  return TS_SUCCESS;
+}
+
+TSReturnCode
+TSRemapNewInstance(int argc, char *argv[], void **ih, char * /* errbuf ATS_UNUSED */, int /* errbuf_size ATS_UNUSED */)
+{
+  Dbg(dbg_ctl, "New instance for client matching %s to %s", argv[0], argv[1]);
+  auto config         = new PluginConfig();
+  config->plugin_type = PluginType::REMAP;
+
+  // Parse parameters
+  if (!read_config_option(argc - 1, const_cast<const char **>(argv + 1), *config)) {
+    delete config;
+    Dbg(dbg_ctl, "Bad arguments");
+    return TS_ERROR;
+  }
+
+  // Create a log file
+  if (!config->log_filename.empty()) {
+    if (!create_log_file(config->log_filename, config->log_handle)) {
+      TSError("[%s] Failed to create log.", PLUGIN_NAME);
+      return TS_ERROR;
+    } else {
+      Dbg(dbg_ctl, "Created log file.");
+    }
+  }
+
+  if (reserve_user_arg(*config) == TS_ERROR) {
+    TSError("[%s] Failed to reserve user arg index.", PLUGIN_NAME);
+    return TS_ERROR;
+  }
+
+  // Create continuation
+  if (config->standalone) {
+    Dbg(dbg_ctl, "Standalone mode. Adding hooks.");
+    config->handler = TSContCreate(main_handler, nullptr);
+    if (config->method.on_client_hello) {
+      TSHttpHookAdd(TS_SSL_CLIENT_HELLO_HOOK, config->handler);
+    }
+    if (config->method.type == Method::Type::CONNECTION_BASED) {
+      TSHttpHookAdd(TS_VCONN_CLOSE_HOOK, config->handler);
+    } else {
+      TSHttpHookAdd(TS_HTTP_TXN_CLOSE_HOOK, config->handler);
+    }
+    TSContDataSet(config->handler, config);
+  }
+
+  *ih = static_cast<void *>(config);
+
+  return TS_SUCCESS;
+}
+
+TSRemapStatus
+TSRemapDoRemap(void *ih, TSHttpTxn rh, TSRemapRequestInfo *rri)
+{
+  auto config = static_cast<PluginConfig *>(ih);
+
+  if (!config || !rri) {
+    TSError("[%s] Invalid private data or RRI or handler.", PLUGIN_NAME);
+    return TSREMAP_NO_REMAP;
+  }
+
+  handle_read_request_hdr(rh, *config);
+
+  return TSREMAP_NO_REMAP;
+}
+
+void
+TSRemapDeleteInstance(void *ih)
+{
+  auto config = static_cast<PluginConfig *>(ih);
+  if (config->handler) {
+    // Destroying the continuation here causes a crash after remap.config reload
+    // Instead of destroying, make it NOP.
+    // TSContDestroy(config->handler);
+    TSContDataSet(config->handler, nullptr);
+  }
+  if (config->log_handle) {
+    flush_log_file(config->log_handle);
+  }
+  delete config;
+}
diff --git a/plugins/experimental/jax_fingerprint/plugin.h b/plugins/experimental/jax_fingerprint/plugin.h
new file mode 100644
index 0000000000..e2bddda4ee
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/plugin.h
@@ -0,0 +1,31 @@
+/** @file
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+
+ */
+
+#pragma once
+
+#include "ts/ts.h"
+
+constexpr char const *PLUGIN_NAME{"jax_fingerprint"};
+constexpr char const *PLUGIN_VENDOR{"Apache Software Foundation"};
+constexpr char const *PLUGIN_SUPPORT_EMAIL{"dev@trafficserver.apache.org"};
+
+extern DbgCtl dbg_ctl;
diff --git a/plugins/experimental/jax_fingerprint/userarg.cc b/plugins/experimental/jax_fingerprint/userarg.cc
new file mode 100644
index 0000000000..f3e47c16b6
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/userarg.cc
@@ -0,0 +1,53 @@
+/** @file
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+ */
+
+#include "plugin.h"
+#include "config.h"
+#include "userarg.h"
+
+int
+reserve_user_arg(PluginConfig &config)
+{
+  std::string name  = PLUGIN_NAME;
+  name             += config.method.name;
+
+  TSUserArgType type;
+  if (config.method.type == Method::Type::CONNECTION_BASED) {
+    type = TS_USER_ARGS_VCONN;
+  } else {
+    type = TS_USER_ARGS_TXN;
+  }
+  int ret = TSUserArgIndexReserve(type, name.c_str(), "used to pass JAx context between hooks", &config.user_arg_index);
+  Dbg(dbg_ctl, "user_arg_name: %s, user_arg_index: %d", name.c_str(), config.user_arg_index);
+  return ret;
+}
+
+void
+set_user_arg(void *container, PluginConfig &config, JAxContext *ctx)
+{
+  TSUserArgSet(container, config.user_arg_index, static_cast<void *>(ctx));
+}
+
+JAxContext *
+get_user_arg(void *container, PluginConfig &config)
+{
+  return static_cast<JAxContext *>(TSUserArgGet(container, config.user_arg_index));
+}
diff --git a/plugins/experimental/jax_fingerprint/userarg.h b/plugins/experimental/jax_fingerprint/userarg.h
new file mode 100644
index 0000000000..3e4d91d7ad
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/userarg.h
@@ -0,0 +1,32 @@
+/** @file
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+
+ */
+
+#pragma once
+
+#include "context.h"
+#include "config.h"
+
+#include "ts/ts.h"
+
+int         reserve_user_arg(PluginConfig &config);
+void        set_user_arg(void *container, PluginConfig &config, JAxContext *ctx);
+JAxContext *get_user_arg(void *container, PluginConfig &config);
diff --git a/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint.test.py b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint.test.py
new file mode 100644
index 0000000000..1240670b01
--- /dev/null
+++ b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint.test.py
@@ -0,0 +1,339 @@
+'''
+Verify the behavior of the jax_fingerprint plugin.
+
+Covers all supported run modes (overwrite, keep, append), setup types
+(global, remap, hybrid), fingerprint methods (JA3, JA4, JA4H), and both
+HTTP/1.1 and HTTP/2 client connections.
+
+Global setup:   plugin in plugin.config, applies to every request.
+Remap setup:    plugin per remap rule, applies only to matched routes.
+Hybrid setup:   global plugin captures the TLS client hello (creates
+                context) while a remap plugin reads that context and
+                sets headers only on matched routes.
+'''
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+import os
+import re
+
+Test.Summary = __doc__
+Test.SkipUnless(Condition.PluginExists('jax_fingerprint.so'))
+
+
+class JaxFingerprintTest:
+    '''Verify the behavior of the jax_fingerprint plugin.'''
+
+    _dns_counter: int = 0
+    _server_counter: int = 0
+    _ts_counter: int = 0
+    _client_counter: int = 0
+
+    def __init__(
+            self, name: str, method: str, setup: str, mode: str = 'overwrite', http2: bool = False, servernames: str = '') -> None:
+        '''Configure test processes for the jax_fingerprint plugin.
+
+        :param name: Descriptive name for this test run.
+        :param method: Fingerprint method: 'JA3', 'JA4', or 'JA4H'.
+        :param setup: Plugin setup type: 'global', 'remap', or 'hybrid'.
+        :param mode: Header write mode: 'overwrite', 'keep', or 'append'.
+        :param http2: If True, the client connects to ATS over HTTP/2 (h2
+            over TLS).  For JA3/JA4 the h2 ALPN in the ClientHello produces
+            a fingerprint distinct from the HTTP/1.1 case.  For JA4H,
+            get_version() detects HTTP/2 via the protocol stack.
+        :param servernames: Comma-separated SNI allowlist passed as
+            --servernames to the plugin.  Connections whose SNI is not in
+            the list are skipped entirely: handle_client_hello returns early,
+            no context is created, and handle_read_request_hdr is a no-op.
+            Only meaningful for CONNECTION_BASED methods (JA3/JA4) in global
+            setup.
+
+        Method notes:
+          - JA3 / JA4 are CONNECTION_BASED (triggered on TLS client hello)
+            and require TLS between the client and ATS.
+          - JA4H is REQUEST_BASED (triggered on HTTP request read) and
+            works over plain HTTP, HTTPS, and HTTP/2.
+
+        Setup notes:
+          - global:  plugin.config entry with --standalone so ATS registers
+            the READ_REQUEST_HDR hook and modifies every request.
+          - remap:   @plugin in remap.config.  For CONNECTION_BASED methods
+            --standalone must also be passed so the remap plugin registers
+            the SSL_CLIENT_HELLO_HOOK globally and can populate the vconn
+            context that TSRemapDoRemap reads later.
+          - hybrid:  global plugin (no --standalone) captures the TLS client
+            hello and stores context on the vconn; a remap plugin (no
+            --standalone) reads that shared context and sets headers only
+            on matched routes.  Both instances share the same user-arg slot
+            because TSUserArgIndexReserve is idempotent for identical names.
+        '''
+        self._name = name
+        self._method = method
+        self._setup = setup
+        self._mode = mode
+        self._http2 = http2
+        self._servernames = servernames
+        # HTTP/2 always runs over TLS (h2 requires TLS).
+        self._needs_tls = method in ('JA3', 'JA4') or http2
+        self._replay_file = self._choose_replay_file()
+
+        tr = Test.AddTestRun(name)
+        self._configure_dns(tr)
+        self._configure_server(tr)
+        self._configure_trafficserver()
+        self._configure_client(tr)
+        Test.AddAwaitFileContainsTestRun(
+            f'Await jax_fingerprint.log for: {self._name}', self._ts.Disk.jax_log.AbsPath, self._method)
+
+    # ------------------------------------------------------------------
+    # Helpers
+    # ------------------------------------------------------------------
+
+    def _choose_replay_file(self) -> str:
+        '''Return the replay YAML to drive this test.
+
+        Key: (servernames, setup, needs_tls, http2, mode)
+        '''
+        key = (bool(self._servernames), self._setup, self._needs_tls, self._http2, self._mode)
+        mapping = {
+            (False, 'global', False, False, 'overwrite'): 'jax_fingerprint_global.replay.yaml',
+            (False, 'global', False, False, 'keep'): 'jax_fingerprint_keep.replay.yaml',
+            (False, 'global', False, False, 'append'): 'jax_fingerprint_append.replay.yaml',
+            (False, 'global', True, False, 'overwrite'): 'jax_fingerprint_tls.replay.yaml',
+            (False, 'global', True, True, 'overwrite'): 'jax_fingerprint_global_h2.replay.yaml',
+            (False, 'remap', False, False, 'overwrite'): 'jax_fingerprint_remap.replay.yaml',
+            (False, 'remap', True, False, 'overwrite'): 'jax_fingerprint_remap_tls.replay.yaml',
+            (False, 'hybrid', True, False, 'overwrite'): 'jax_fingerprint_remap_tls.replay.yaml',
+            (True, 'global', True, False, 'overwrite'): 'jax_fingerprint_servernames.replay.yaml',
+            (True, 'hybrid', True, False, 'overwrite'): 'jax_fingerprint_hybrid_servernames.replay.yaml',
+        }
+        return mapping[key]
+
+    def _build_remap_plugin_line(self, add_standalone: bool = False) -> str:
+        '''Build the @plugin / @pparam fragment for a remap.config line.'''
+        parts = [
+            '@plugin=jax_fingerprint.so',
+            '@pparam=--method',
+            f'@pparam={self._method}',
+            '@pparam=--header',
+            '@pparam=x-jax',
+            '@pparam=--via-header',
+            '@pparam=x-jax-via',
+            '@pparam=--log-filename',
+            '@pparam=jax_fingerprint',
+        ]
+        if self._mode != 'overwrite':
+            parts.extend(['@pparam=--mode', f'@pparam={self._mode}'])
+        if add_standalone:
+            parts.append('@pparam=--standalone')
+        return ' '.join(parts)
+
+    # ------------------------------------------------------------------
+    # Test-process configuration
+    # ------------------------------------------------------------------
+
+    def _configure_dns(self, tr: 'TestRun') -> None:
+        '''Configure a nameserver for the test.'''
+        name = f'dns{JaxFingerprintTest._dns_counter}'
+        self._dns = tr.MakeDNServer(name, default='127.0.0.1')
+        JaxFingerprintTest._dns_counter += 1
+
+    def _configure_server(self, tr: 'TestRun') -> None:
+        '''Configure the origin (verifier) server.'''
+        name = f'server{JaxFingerprintTest._server_counter}'
+        self._server = tr.AddVerifierServerProcess(name, self._replay_file)
+        JaxFingerprintTest._server_counter += 1
+
+        # For remap / hybrid tests the second session reaches the server
+        # with the fingerprint header already appended by ATS; verify it.
+        if self._servernames:
+            # Only the SNI-matched session carries the fingerprint header.
+            uuid_key = 'hybrid-servernames-in-filter' if self._setup == 'hybrid' else 'servernames-in-filter'
+            self._server.Streams.All += Testers.ContainsExpression(uuid_key, 'Verify the allowed-SNI request reached the server.')
+            self._server.Streams.All += Testers.ContainsExpression(
+                r'x-jax:', 'Verify the fingerprint header was forwarded for the allowed SNI.', reflags=re.IGNORECASE)
+        elif self._setup in ('remap', 'hybrid'):
+            uuid_key = 'remap-tls-plugin-request' if self._needs_tls else 'remap-plugin-request'
+            self._server.Streams.All += Testers.ContainsExpression(uuid_key, 'Verify the matched-route request reached the server.')
+            self._server.Streams.All += Testers.ContainsExpression(
+                r'x-jax:', 'Verify the fingerprint header was forwarded.', reflags=re.IGNORECASE)
+        else:
+            # global tests - all requests carry the fingerprint header
+            self._server.Streams.All += Testers.ContainsExpression(
+                r'x-jax:', 'Verify the fingerprint header was forwarded.', reflags=re.IGNORECASE)
+
+    def _configure_trafficserver(self) -> None:
+        '''Configure Traffic Server and its plugin / remap rules.'''
+        name = f'ts{JaxFingerprintTest._ts_counter}'
+        self._ts = Test.MakeATSProcess(name, enable_cache=False, enable_tls=True)
+        JaxFingerprintTest._ts_counter += 1
+
+        self._ts.addDefaultSSLFiles()
+        self._ts.Disk.ssl_multicert_yaml.AddLines(
+            """
+ssl_multicert:
+  - dest_ip: "*"
+    ssl_cert_name: server.pem
+    ssl_key_name: server.key
+""".split("\n"))
+
+        if self._needs_tls:
+            server_port = self._server.Variables.https_port
+            scheme = 'https'
+        else:
+            server_port = self._server.Variables.http_port
+            scheme = 'http'
+
+        self._ts.Disk.records_config.update(
+            {
+                'proxy.config.ssl.server.cert.path': self._ts.Variables.SSLDir,
+                'proxy.config.ssl.server.private_key.path': self._ts.Variables.SSLDir,
+                'proxy.config.ssl.client.verify.server.policy': 'PERMISSIVE',
+                'proxy.config.dns.nameservers': f"127.0.0.1:{self._dns.Variables.Port}",
+                'proxy.config.dns.resolv_conf': 'NULL',
+                'proxy.config.proxy_name': 'test.proxy.test',
+                'proxy.config.diags.debug.enabled': 1,
+                'proxy.config.diags.debug.tags': 'jax_fingerprint|http',
+            })
+
+        log_path = os.path.join(self._ts.Variables.LOGDIR, 'jax_fingerprint.log')
+        self._ts.Disk.File(log_path, id='jax_log')
+        self._ts.Disk.jax_log.Content += Testers.ContainsExpression(
+            rf'.*{self._method}:.*', f'Verify the log contains a {self._method} fingerprint.', reflags=re.MULTILINE)
+
+        backend = f'{scheme}://jax.backend.test:{server_port}'
+        backend_no_plugin = f'{scheme}://jax.backend.test:{server_port}'
+
+        if self._setup == 'global':
+            global_args = (
+                f'--method {self._method} '
+                f'--header x-jax '
+                f'--via-header x-jax-via '
+                f'--log-filename jax_fingerprint '
+                f'--standalone')
+            if self._mode != 'overwrite':
+                global_args += f' --mode {self._mode}'
+            if self._servernames:
+                global_args += f' --servernames {self._servernames}'
+            self._ts.Disk.plugin_config.AddLine(f'jax_fingerprint.so {global_args}')
+            self._ts.Disk.remap_config.AddLine(f'map {scheme}://jax.server.test {backend}')
+            if self._servernames:
+                # Second remap rule for the SNI that is NOT in the allowlist.
+                self._ts.Disk.remap_config.AddLine(f'map {scheme}://jax-filtered.server.test {backend}')
+
+        elif self._setup == 'remap':
+            # Route without plugin (session 1 in replay file)
+            self._ts.Disk.remap_config.AddLine(f'map {scheme}://jax-no-plugin.server.test {backend_no_plugin}')
+            # Route with plugin (session 2 in replay file)
+            # CONNECTION_BASED methods need --standalone so the remap plugin
+            # registers the SSL_CLIENT_HELLO_HOOK to populate the vconn context.
+            remap_line = self._build_remap_plugin_line(add_standalone=self._needs_tls)
+            self._ts.Disk.remap_config.AddLine(f'map {scheme}://jax.server.test {backend} {remap_line}')
+
+        elif self._setup == 'hybrid':
+            # Global plugin: registers SSL_CLIENT_HELLO_HOOK to capture the
+            # TLS handshake and store context on the vconn.  No --standalone
+            # means no READ_REQUEST_HDR hook, so headers are never set here.
+            global_plugin_args = f'--method {self._method}'
+            if self._servernames:
+                global_plugin_args += f' --servernames {self._servernames}'
+            self._ts.Disk.plugin_config.AddLine(f'jax_fingerprint.so {global_plugin_args}')
+            remap_line = self._build_remap_plugin_line(add_standalone=False)
+            if self._servernames:
+                # Both routes have the remap plugin.  Only the SNI-allowed
+                # connection has a context on the vconn, so only that request
+                # gets fingerprint headers even though both routes are mapped.
+                self._ts.Disk.remap_config.AddLine(
+                    f'map https://jax.server.test https://jax.backend.test:{server_port} {remap_line}')
+                self._ts.Disk.remap_config.AddLine(
+                    f'map https://jax-filtered.server.test https://jax.backend.test:{server_port} {remap_line}')
+            else:
+                # Route without remap plugin: context is captured but no headers set.
+                self._ts.Disk.remap_config.AddLine(f'map https://jax-no-plugin.server.test https://jax.backend.test:{server_port}')
+                # Route with remap plugin: reads shared vconn context, sets headers.
+                self._ts.Disk.remap_config.AddLine(
+                    f'map https://jax.server.test https://jax.backend.test:{server_port} {remap_line}')
+
+    def _configure_client(self, tr: 'TestRun') -> None:
+        '''Configure the verifier client.'''
+        name = f'client{JaxFingerprintTest._client_counter}'
+        p = tr.AddVerifierClientProcess(
+            name, self._replay_file, http_ports=[self._ts.Variables.port], https_ports=[self._ts.Variables.ssl_port])
+        JaxFingerprintTest._client_counter += 1
+
+        p.StartBefore(self._dns)
+        p.StartBefore(self._server)
+        p.StartBefore(self._ts)
+        tr.StillRunningAfter = self._ts
+
+
+# ======================================================================
+# Test instances
+# ======================================================================
+
+# --- Global setup -------------------------------------------------------
+
+# All HTTP/1.1 requests receive a JA4H fingerprint header (overwrite mode).
+JaxFingerprintTest('Global JA4H overwrite', 'JA4H', 'global')
+
+# All TLS requests receive a JA3 fingerprint header (overwrite mode).
+JaxFingerprintTest('Global JA3 overwrite', 'JA3', 'global')
+
+# --- Remap setup --------------------------------------------------------
+
+# Only requests matching the remap rule receive a JA4H header.
+JaxFingerprintTest('Remap JA4H', 'JA4H', 'remap')
+
+# Remap plugin with --standalone captures TLS client hellos globally and
+# sets JA3 headers only on the matched route.
+JaxFingerprintTest('Remap JA3 standalone', 'JA3', 'remap')
+
+# --- Hybrid setup -------------------------------------------------------
+
+# Global plugin captures TLS client hellos (creates vconn context).
+# Remap plugin reads that shared context and sets headers on matched routes.
+JaxFingerprintTest('Hybrid JA4', 'JA4', 'hybrid')
+
+# --- HTTP/2 (h2 over TLS) -----------------------------------------------
+
+# JA4: the h2 ALPN in the ClientHello produces a different fingerprint than
+# the HTTP/1.1 case – exercises the full TLS extension path with h2 ALPN.
+JaxFingerprintTest('Global JA4 HTTP/2', 'JA4', 'global', http2=True)
+
+# JA4H: exercises the HTTP/2 branch of get_version() which detects h2 via
+# TSHttpTxnClientProtocolStackContains.
+JaxFingerprintTest('Global JA4H HTTP/2', 'JA4H', 'global', http2=True)
+
+# keep mode: existing x-jax / x-jax-via headers are not overwritten.
+JaxFingerprintTest('Global JA4H keep mode', 'JA4H', 'global', mode='keep')
+
+# append mode: fingerprint / proxy name are appended to existing header values.
+JaxFingerprintTest('Global JA4H append mode', 'JA4H', 'global', mode='append')
+
+# --- SNI allowlist (--servernames) --------------------------------------
+
+# --servernames restricts fingerprinting to connections whose TLS SNI is in
+# the list.  Connections with a non-matching SNI are skipped at the client-
+# hello hook: no context is created and handle_read_request_hdr is a no-op,
+# so neither the fingerprint header nor the via header are set.
+JaxFingerprintTest('Global JA4 servernames', 'JA4', 'global', servernames='jax.server.test')
+
+# --- Hybrid + SNI allowlist (most common production pattern) ---------------
+
+# Global plugin captures TLS client hellos for allowed SNIs only.
+# Remap plugin sets headers on both routes, but only the SNI-allowed
+# connection has a vconn context, so only that request gets headers.
+JaxFingerprintTest('Hybrid JA4 servernames', 'JA4', 'hybrid', servernames='jax.server.test')
diff --git a/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_append.replay.yaml b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_append.replay.yaml
new file mode 100644
index 0000000000..3381826bc9
--- /dev/null
+++ b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_append.replay.yaml
@@ -0,0 +1,114 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+# Used by: global JA4H append mode test.
+#
+# The jax_fingerprint plugin is loaded globally with --method JA4H
+# --standalone --mode append.
+#
+# append mode behaviour (from header.cc):
+#   - If the header field is absent: create it with the new value.
+#   - If the header field is already present: insert the new value as an
+#     additional value in the last duplicate field.  HTTP clients and
+#     intermediaries represent this as a comma-separated list,
+#     e.g. "upstream-via, test.proxy.test".
+
+meta:
+  version: "1.0"
+
+sessions:
+
+# Session 1: no pre-existing x-jax-via – plugin creates it.
+- protocol:
+  - name: http
+    version: 1
+  - name: tcp
+  - name: ip
+
+  transactions:
+
+  - client-request:
+      method: GET
+      url: /resource-no-via
+      version: '1.1'
+      headers:
+        fields:
+        - [ Host, jax.server.test ]
+        - [ Content-Length, 0 ]
+        - [ uuid, append-no-existing-via ]
+
+    # No existing via header – plugin sets it to the proxy name.
+    proxy-request:
+      headers:
+        fields:
+        - [ x-jax, { as: present } ]
+        - [ x-jax-via, { value: 'test.proxy.test', as: equal } ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Length, 2 ]
+        - [ Connection, close ]
+      content:
+        encoding: plain
+        data: "OK"
+
+    proxy-response:
+      status: 200
+
+# Session 2: pre-existing x-jax-via – append mode adds proxy name to it.
+- protocol:
+  - name: http
+    version: 1
+  - name: tcp
+  - name: ip
+
+  transactions:
+
+  - client-request:
+      method: GET
+      url: /resource-with-via
+      version: '1.1'
+      headers:
+        fields:
+        - [ Host, jax.server.test ]
+        - [ Content-Length, 0 ]
+        - [ uuid, append-existing-via ]
+        - [ x-jax-via, upstream-via ]
+
+    # append mode: proxy name is inserted as an additional value, yielding
+    # the comma-separated form "upstream-via, test.proxy.test".
+    proxy-request:
+      headers:
+        fields:
+        - [ x-jax, { as: present } ]
+        - [ x-jax-via, { value: 'upstream-via, test.proxy.test', as: equal } ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Length, 2 ]
+        - [ Connection, close ]
+      content:
+        encoding: plain
+        data: "OK"
+
+    proxy-response:
+      status: 200
diff --git a/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_global.replay.yaml b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_global.replay.yaml
new file mode 100644
index 0000000000..4a748d3e11
--- /dev/null
+++ b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_global.replay.yaml
@@ -0,0 +1,111 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+# Used by: global JA4H overwrite test.
+#
+# The jax_fingerprint plugin is loaded globally with --method JA4H
+# --standalone --mode overwrite.  Every request should have the
+# fingerprint header (x-jax) and via header (x-jax-via) written by ATS
+# regardless of any pre-existing values.
+
+meta:
+  version: "1.0"
+
+sessions:
+
+# Session 1: no pre-existing x-jax header – plugin adds it.
+- protocol:
+  - name: http
+    version: 1
+  - name: tcp
+  - name: ip
+
+  transactions:
+
+  - client-request:
+      method: GET
+      url: /resource
+      version: '1.1'
+      headers:
+        fields:
+        - [ Host, jax.server.test ]
+        - [ Content-Length, 0 ]
+        - [ uuid, global-ja4h-request ]
+
+    # Global plugin (overwrite) must add both headers.
+    proxy-request:
+      headers:
+        fields:
+        - [ x-jax, { as: present } ]
+        - [ x-jax-via, { value: 'test.proxy.test', as: equal } ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Length, 2 ]
+        - [ Connection, close ]
+      content:
+        encoding: plain
+        data: "OK"
+
+    proxy-response:
+      status: 200
+
+# Session 2: pre-existing x-jax header – overwrite mode replaces it.
+- protocol:
+  - name: http
+    version: 1
+  - name: tcp
+  - name: ip
+
+  transactions:
+
+  - client-request:
+      method: GET
+      url: /resource-with-header
+      version: '1.1'
+      headers:
+        fields:
+        - [ Host, jax.server.test ]
+        - [ Content-Length, 0 ]
+        - [ uuid, global-ja4h-overwrite ]
+        - [ x-jax, upstream-fingerprint ]
+        - [ x-jax-via, upstream-via ]
+
+    # In overwrite mode the plugin replaces the upstream values.
+    # x-jax contains the freshly computed fingerprint (dynamic value,
+    # so we only assert presence); x-jax-via is set to the proxy name.
+    proxy-request:
+      headers:
+        fields:
+        - [ x-jax, { as: present } ]
+        - [ x-jax-via, { value: 'test.proxy.test', as: equal } ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Length, 2 ]
+        - [ Connection, close ]
+      content:
+        encoding: plain
+        data: "OK"
+
+    proxy-response:
+      status: 200
diff --git a/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_global_h2.replay.yaml b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_global_h2.replay.yaml
new file mode 100644
index 0000000000..509416d843
--- /dev/null
+++ b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_global_h2.replay.yaml
@@ -0,0 +1,121 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+# Used by: global JA4 HTTP/2 test and global JA4H HTTP/2 test.
+#
+# The client connects to ATS over HTTP/2 (h2 over TLS).  ATS forwards
+# to the backend over HTTPS/1.1.
+#
+# JA3/JA4: the TLS ClientHello includes an h2 ALPN extension, so the
+# computed fingerprint differs from a plain HTTP/1.1 TLS session.
+#
+# JA4H: TxnDatasource::get_version() detects HTTP/2 via
+# TSHttpTxnClientProtocolStackContains(txn, "h2"), producing an
+# h2-specific fingerprint value.
+#
+# In both cases the plugin writes x-jax and x-jax-via into the
+# HTTP/1.1 proxy-request that ATS sends upstream.  Header field names
+# are already lowercase so HTTP/2's mandatory-lowercase rule does not
+# introduce any new behaviour to verify here.
+
+meta:
+  version: "1.0"
+
+sessions:
+
+# Session 1: HTTP/2 request without pre-existing x-jax – plugin adds it.
+- protocol:
+  - name: http
+    version: 2
+  - name: tls
+    sni: jax.server.test
+  - name: tcp
+  - name: ip
+
+  transactions:
+
+  - client-request:
+      headers:
+        fields:
+        - [ :method, GET ]
+        - [ :scheme, https ]
+        - [ :authority, jax.server.test ]
+        - [ :path, /resource-h2 ]
+        - [ uuid, global-h2-request ]
+
+    # ATS translates to HTTP/1.1 toward the backend; the plugin must have
+    # written both headers into this outgoing request.
+    proxy-request:
+      headers:
+        fields:
+        - [ x-jax, { as: present } ]
+        - [ x-jax-via, { value: 'test.proxy.test', as: equal } ]
+
+    server-response:
+      headers:
+        fields:
+        - [ :status, 200 ]
+        - [ Content-Length, 2 ]
+        - [ X-Response, global-h2-response ]
+        - [ Connection, close ]
+
+    proxy-response:
+      headers:
+        fields:
+        - [ :status, { value: 200, as: equal } ]
+
+# Session 2: HTTP/2 request with pre-existing x-jax – overwrite mode
+# replaces the upstream value with the freshly computed fingerprint.
+- protocol:
+  - name: http
+    version: 2
+  - name: tls
+    sni: jax.server.test
+  - name: tcp
+  - name: ip
+
+  transactions:
+
+  - client-request:
+      headers:
+        fields:
+        - [ :method, GET ]
+        - [ :scheme, https ]
+        - [ :authority, jax.server.test ]
+        - [ :path, /resource-h2-with-header ]
+        - [ uuid, global-h2-overwrite ]
+        - [ x-jax, upstream-fingerprint ]
+        - [ x-jax-via, upstream-via ]
+
+    # Overwrite mode: both upstream values must be replaced by the plugin.
+    proxy-request:
+      headers:
+        fields:
+        - [ x-jax, { as: present } ]
+        - [ x-jax-via, { value: 'test.proxy.test', as: equal } ]
+
+    server-response:
+      headers:
+        fields:
+        - [ :status, 200 ]
+        - [ Content-Length, 2 ]
+        - [ X-Response, global-h2-overwrite-response ]
+        - [ Connection, close ]
+
+    proxy-response:
+      headers:
+        fields:
+        - [ :status, { value: 200, as: equal } ]
diff --git a/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_hybrid_servernames.replay.yaml b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_hybrid_servernames.replay.yaml
new file mode 100644
index 0000000000..2d1e53cdbf
--- /dev/null
+++ b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_hybrid_servernames.replay.yaml
@@ -0,0 +1,124 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+# Used by: hybrid JA4 servernames test.
+#
+# The global plugin (no --standalone) is loaded with --method JA4
+# --servernames jax.server.test.  It registers SSL_CLIENT_HELLO_HOOK
+# and, for connections whose SNI is in the allowlist, stores a JAxContext
+# on the vconn user-arg slot.  For connections whose SNI is NOT in the
+# allowlist it calls TSVConnReenable and returns immediately -- no context
+# is ever created.
+#
+# Both remap rules carry the remap plugin (no --standalone).  TSRemapDoRemap
+# calls handle_read_request_hdr which looks for a JAxContext on the vconn:
+#   - jax.server.test (SNI in allowlist)     : context present → headers added
+#   - jax-filtered.server.test (SNI absent)  : context absent  → headers absent
+#
+# This is the most common production pattern: a single global instance
+# captures TLS client hellos while per-route remap plugins control which
+# responses actually get fingerprint headers.
+
+meta:
+  version: "1.0"
+
+sessions:
+
+# Session 1: SNI matches the allowlist – global plugin creates context,
+# remap plugin reads it and sets fingerprint headers.
+- protocol:
+  - name: tls
+    sni: jax.server.test
+  - name: tcp
+  - name: ip
+
+  transactions:
+
+  - client-request:
+      method: GET
+      url: /resource
+      version: '1.1'
+      headers:
+        fields:
+        - [ Host, jax.server.test ]
+        - [ Content-Length, 0 ]
+        - [ uuid, hybrid-servernames-in-filter ]
+
+    # SNI is in the allowlist – remap plugin must have set both headers.
+    proxy-request:
+      headers:
+        fields:
+        - [ x-jax, { as: present } ]
+        - [ x-jax-via, { value: 'test.proxy.test', as: equal } ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Length, 10 ]
+        - [ X-Response, hybrid-sni-allowed-response ]
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Response, { value: 'hybrid-sni-allowed-response', as: equal } ]
+
+# Session 2: SNI does NOT match the allowlist – global plugin skips the
+# connection (no context on vconn).  Even though the remap rule carries the
+# plugin, handle_read_request_hdr finds no context and returns without
+# touching any headers.
+- protocol:
+  - name: tls
+    sni: jax-filtered.server.test
+  - name: tcp
+  - name: ip
+
+  transactions:
+
+  - client-request:
+      method: GET
+      url: /resource
+      version: '1.1'
+      headers:
+        fields:
+        - [ Host, jax-filtered.server.test ]
+        - [ Content-Length, 0 ]
+        - [ uuid, hybrid-servernames-filtered ]
+
+    # SNI is not in the allowlist – neither header must appear.
+    proxy-request:
+      headers:
+        fields:
+        - [ x-jax, { as: absent } ]
+        - [ x-jax-via, { as: absent } ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Length, 12 ]
+        - [ X-Response, hybrid-sni-filtered-response ]
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Response, { value: 'hybrid-sni-filtered-response', as: equal } ]
diff --git a/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_keep.replay.yaml b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_keep.replay.yaml
new file mode 100644
index 0000000000..c3b4ff9bf8
--- /dev/null
+++ b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_keep.replay.yaml
@@ -0,0 +1,111 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+# Used by: global JA4H keep mode test.
+#
+# The jax_fingerprint plugin is loaded globally with --method JA4H
+# --standalone --mode keep.
+#
+# keep mode: if the fingerprint header (x-jax) or via header (x-jax-via)
+# is already present on the incoming request, the plugin must leave them
+# unchanged.  If they are absent the plugin sets them as usual.
+
+meta:
+  version: "1.0"
+
+sessions:
+
+# Session 1: no pre-existing headers – plugin adds them (same as overwrite).
+- protocol:
+  - name: http
+    version: 1
+  - name: tcp
+  - name: ip
+
+  transactions:
+
+  - client-request:
+      method: GET
+      url: /resource-no-header
+      version: '1.1'
+      headers:
+        fields:
+        - [ Host, jax.server.test ]
+        - [ Content-Length, 0 ]
+        - [ uuid, keep-no-existing-header ]
+
+    # Headers were absent – plugin must set them.
+    proxy-request:
+      headers:
+        fields:
+        - [ x-jax, { as: present } ]
+        - [ x-jax-via, { value: 'test.proxy.test', as: equal } ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Length, 2 ]
+        - [ Connection, close ]
+      content:
+        encoding: plain
+        data: "OK"
+
+    proxy-response:
+      status: 200
+
+# Session 2: pre-existing headers present – keep mode must preserve them.
+- protocol:
+  - name: http
+    version: 1
+  - name: tcp
+  - name: ip
+
+  transactions:
+
+  - client-request:
+      method: GET
+      url: /resource-with-header
+      version: '1.1'
+      headers:
+        fields:
+        - [ Host, jax.server.test ]
+        - [ Content-Length, 0 ]
+        - [ uuid, keep-existing-header ]
+        - [ x-jax, upstream-fingerprint ]
+        - [ x-jax-via, upstream-via ]
+
+    # keep mode: original upstream values must be forwarded unchanged.
+    proxy-request:
+      headers:
+        fields:
+        - [ x-jax, { value: 'upstream-fingerprint', as: equal } ]
+        - [ x-jax-via, { value: 'upstream-via', as: equal } ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Length, 2 ]
+        - [ Connection, close ]
+      content:
+        encoding: plain
+        data: "OK"
+
+    proxy-response:
+      status: 200
diff --git a/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_remap.replay.yaml b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_remap.replay.yaml
new file mode 100644
index 0000000000..8fc04a3319
--- /dev/null
+++ b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_remap.replay.yaml
@@ -0,0 +1,109 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+# Used by: remap JA4H test.
+#
+# Two HTTP sessions reach the same origin server, but through different
+# remap rules:
+#   jax-no-plugin.server.test  – no jax_fingerprint plugin configured
+#   jax.server.test            – jax_fingerprint plugin with JA4H method
+#
+# The plugin must add fingerprint headers only for the second route.
+
+meta:
+  version: "1.0"
+
+sessions:
+
+# Session 1: request to the route without the plugin – no fingerprint headers.
+- protocol:
+  - name: http
+    version: 1
+  - name: tcp
+  - name: ip
+
+  transactions:
+
+  - client-request:
+      method: GET
+      url: /resource
+      version: '1.1'
+      headers:
+        fields:
+        - [ Host, jax-no-plugin.server.test ]
+        - [ Content-Length, 0 ]
+        - [ uuid, remap-no-plugin-request ]
+
+    # No plugin on this route – headers must be absent.
+    proxy-request:
+      headers:
+        fields:
+        - [ x-jax, { as: absent } ]
+        - [ x-jax-via, { as: absent } ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Length, 9 ]
+        - [ Connection, close ]
+      content:
+        encoding: plain
+        data: "No plugin"
+
+    proxy-response:
+      status: 200
+
+# Session 2: request to the route with the plugin – fingerprint headers added.
+- protocol:
+  - name: http
+    version: 1
+  - name: tcp
+  - name: ip
+
+  transactions:
+
+  - client-request:
+      method: GET
+      url: /resource
+      version: '1.1'
+      headers:
+        fields:
+        - [ Host, jax.server.test ]
+        - [ Content-Length, 0 ]
+        - [ uuid, remap-plugin-request ]
+
+    # Plugin is active for this route – both headers must be present.
+    proxy-request:
+      headers:
+        fields:
+        - [ x-jax, { as: present } ]
+        - [ x-jax-via, { value: 'test.proxy.test', as: equal } ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Length, 11 ]
+        - [ Connection, close ]
+      content:
+        encoding: plain
+        data: "With plugin"
+
+    proxy-response:
+      status: 200
diff --git a/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_remap_tls.replay.yaml b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_remap_tls.replay.yaml
new file mode 100644
index 0000000000..9b21a10b77
--- /dev/null
+++ b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_remap_tls.replay.yaml
@@ -0,0 +1,120 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+# Used by: remap JA3 standalone test AND hybrid JA4 test.
+#
+# Two TLS sessions reach the same origin server through different remap rules:
+#   jax-no-plugin.server.test  – no jax_fingerprint plugin for this route
+#   jax.server.test            – jax_fingerprint plugin active for this route
+#
+# For the remap+standalone test: the remap plugin registers the
+# SSL_CLIENT_HELLO_HOOK globally (--standalone) so it can populate the
+# vconn context that TSRemapDoRemap later reads.
+#
+# For the hybrid test: a global plugin (no --standalone) captures the TLS
+# client hello and stores context on the vconn; a remap plugin (no
+# --standalone) reads that shared context and writes headers only for the
+# matched route.
+#
+# In both cases the first session must have no fingerprint headers and the
+# second session must have them.
+
+meta:
+  version: "1.0"
+
+sessions:
+
+# Session 1: TLS request to route without plugin – no fingerprint headers.
+- protocol:
+  - name: tls
+    sni: jax-no-plugin.server.test
+  - name: tcp
+  - name: ip
+
+  transactions:
+
+  - client-request:
+      method: GET
+      url: /resource
+      version: '1.1'
+      headers:
+        fields:
+        - [ Host, jax-no-plugin.server.test ]
+        - [ Content-Length, 0 ]
+        - [ uuid, remap-tls-no-plugin-request ]
+
+    # No plugin on this route – headers must be absent.
+    proxy-request:
+      headers:
+        fields:
+        - [ x-jax, { as: absent } ]
+        - [ x-jax-via, { as: absent } ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Length, 9 ]
+        - [ X-Response, remap-tls-no-plugin-response ]
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Response, { value: 'remap-tls-no-plugin-response', as: equal } ]
+
+# Session 2: TLS request to route with plugin – fingerprint headers added.
+- protocol:
+  - name: tls
+    sni: jax.server.test
+  - name: tcp
+  - name: ip
+
+  transactions:
+
+  - client-request:
+      method: GET
+      url: /resource
+      version: '1.1'
+      headers:
+        fields:
+        - [ Host, jax.server.test ]
+        - [ Content-Length, 0 ]
+        - [ uuid, remap-tls-plugin-request ]
+
+    # Plugin is active for this route – both headers must be present.
+    proxy-request:
+      headers:
+        fields:
+        - [ x-jax, { as: present } ]
+        - [ x-jax-via, { value: 'test.proxy.test', as: equal } ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Length, 11 ]
+        - [ X-Response, remap-tls-plugin-response ]
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Response, { value: 'remap-tls-plugin-response', as: equal } ]
diff --git a/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_servernames.replay.yaml b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_servernames.replay.yaml
new file mode 100644
index 0000000000..994dde2266
--- /dev/null
+++ b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_servernames.replay.yaml
@@ -0,0 +1,114 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+# Used by: global JA3 servernames test.
+#
+# The plugin is loaded globally with --servernames jax.server.test.
+# In handle_client_hello, if the incoming SNI is not in the allowlist the
+# plugin calls TSVConnReenable and returns immediately.  No JAxContext is
+# ever created for that vconn, so handle_read_request_hdr returns early
+# ("No context. Skipping.") without touching any headers.
+#
+# Two TLS sessions reach the same backend through different remap rules:
+#   jax.server.test          – SNI is IN the allowlist  → headers added
+#   jax-filtered.server.test – SNI NOT in the allowlist → headers absent
+
+meta:
+  version: "1.0"
+
+sessions:
+
+# Session 1: SNI matches the allowlist – fingerprint headers must be added.
+- protocol:
+  - name: tls
+    sni: jax.server.test
+  - name: tcp
+  - name: ip
+
+  transactions:
+
+  - client-request:
+      method: GET
+      url: /resource
+      version: '1.1'
+      headers:
+        fields:
+        - [ Host, jax.server.test ]
+        - [ Content-Length, 0 ]
+        - [ uuid, servernames-in-filter ]
+
+    # SNI is in the allowlist – plugin must have computed and set both headers.
+    proxy-request:
+      headers:
+        fields:
+        - [ x-jax, { as: present } ]
+        - [ x-jax-via, { value: 'test.proxy.test', as: equal } ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Length, 10 ]
+        - [ X-Response, in-filter-response ]
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Response, { value: 'in-filter-response', as: equal } ]
+
+# Session 2: SNI does NOT match the allowlist – plugin skips the connection.
+- protocol:
+  - name: tls
+    sni: jax-filtered.server.test
+  - name: tcp
+  - name: ip
+
+  transactions:
+
+  - client-request:
+      method: GET
+      url: /resource
+      version: '1.1'
+      headers:
+        fields:
+        - [ Host, jax-filtered.server.test ]
+        - [ Content-Length, 0 ]
+        - [ uuid, servernames-filtered ]
+
+    # SNI is not in the allowlist – plugin must not have touched either header.
+    proxy-request:
+      headers:
+        fields:
+        - [ x-jax, { as: absent } ]
+        - [ x-jax-via, { as: absent } ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Length, 12 ]
+        - [ X-Response, filtered-response ]
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Response, { value: 'filtered-response', as: equal } ]
diff --git a/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_tls.replay.yaml b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_tls.replay.yaml
new file mode 100644
index 0000000000..5bedf633c0
--- /dev/null
+++ b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_tls.replay.yaml
@@ -0,0 +1,111 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+# Used by: global JA3 overwrite test.
+#
+# The jax_fingerprint plugin is loaded globally with --method JA3
+# --standalone --mode overwrite.  TLS/HTTPS requests should have the
+# JA3 fingerprint header (x-jax) and via header (x-jax-via) set by ATS.
+
+meta:
+  version: "1.0"
+
+sessions:
+
+# Session 1: TLS/HTTPS request without pre-existing x-jax – plugin adds it.
+- protocol:
+  - name: tls
+    sni: jax.server.test
+  - name: tcp
+  - name: ip
+
+  transactions:
+
+  - client-request:
+      method: GET
+      url: /resource
+      version: '1.1'
+      headers:
+        fields:
+        - [ Host, jax.server.test ]
+        - [ Content-Length, 0 ]
+        - [ uuid, global-ja3-request ]
+
+    # Global plugin (overwrite) must add both headers.
+    proxy-request:
+      headers:
+        fields:
+        - [ x-jax, { as: present } ]
+        - [ x-jax-via, { value: 'test.proxy.test', as: equal } ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Length, 2 ]
+        - [ X-Response, global-ja3-response ]
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Response, { value: 'global-ja3-response', as: equal } ]
+
+# Session 2: TLS/HTTPS request with pre-existing x-jax – overwrite mode
+# replaces the upstream fingerprint with the one computed by ATS.
+- protocol:
+  - name: tls
+    sni: jax.server.test
+  - name: tcp
+  - name: ip
+
+  transactions:
+
+  - client-request:
+      method: GET
+      url: /resource-with-header
+      version: '1.1'
+      headers:
+        fields:
+        - [ Host, jax.server.test ]
+        - [ Content-Length, 0 ]
+        - [ uuid, global-ja3-overwrite ]
+        - [ x-jax, upstream-fingerprint ]
+        - [ x-jax-via, upstream-via ]
+
+    # Overwrite mode: upstream values are replaced by the plugin.
+    proxy-request:
+      headers:
+        fields:
+        - [ x-jax, { as: present } ]
+        - [ x-jax-via, { value: 'test.proxy.test', as: equal } ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Length, 2 ]
+        - [ X-Response, global-ja3-overwrite-response ]
+        - [ Connection, close ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Response, { value: 'global-ja3-overwrite-response', as: equal } ]