DEV Community

Cover image for Mitigating Session Hijacking with JA4 Session Binding and AWS WAF Dynamic Label Interpolation

Mitigating Session Hijacking with JA4 Session Binding and AWS WAF Dynamic Label Interpolation

So, how are you protecting your applications against session hijacking?

For many web applications, the harsh reality is that once a session cookie is stolen, the game is basically over. With infostealer malware now exfiltrating cookies along with everything else, the assumption that “possession of the cookie equals proof of identity” is gradually becoming less reliable.

In this article, I will show how to use AWS WAF Dynamic Label Interpolation to pass a JA4 TLS fingerprint to your application and bind it to the user’s session. With this design, even if an attacker steals a session cookie, the application can detect its use when the attacker’s TLS stack differs from that of the legitimate user.

I previously wrote about mitigating session hijacking with an “Impossible Location” check. You can think of this article as a location-independent version of that approach.

The other articles in this series are listed below. This article is also written to stand on its own, so you can continue reading without them.

Forwarding JA4 to the Application: A Recap of Article 2

JA4 is a fingerprint of the client implementation calculated from the TLS ClientHello. Chrome, curl, and Python’s requests library produce different values because they use different TLS stacks.

The important point is that a JA4 fingerprint cannot be reproduced merely by stealing a cookie. JA4 reflects characteristics of the TLS stack itself, so producing the same value requires the attacker to imitate the victim’s TLS implementation as well.

AWS WAF Dynamic Label Interpolation lets you forward JA4 to the origin with a single Count rule. No CloudFront origin request policy configuration is required for this path.

{
  "Name": "forward-ja4",
  "Statement": { "...": "A condition that matches all requests" },
  "Action": {
    "Count": {
      "CustomRequestHandling": {
        "InsertHeaders": [
          { "Name": "ja4", "Value": "${awswaf:ja4:}" }
        ]
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The origin receives the value in the x-amzn-waf-ja4 header. Because AWS WAF inserts this header, the application can treat it as a server-side signal that the client cannot directly spoof—provided that there is no route to the origin that bypasses WAF.

If clients can reach the origin directly, the header becomes little more than a self-declared value. The origin must therefore reject traffic that does not come through CloudFront.

Implementing Session Binding

The basic implementation is simple: record the JA4 fingerprint in the session after a successful login, then compare it with the fingerprint observed on subsequent requests.

// After a successful login
function onLoginSuccess(req, session) {
    session.boundFingerprints = [req.headers['x-amzn-waf-ja4']];
    session.fingerprintAlerts = 0;
}

// Authentication middleware
function verifySession(req, res, next) {
    const current = req.headers['x-amzn-waf-ja4'];
    const bound = req.session.boundFingerprints;

    if (bound.includes(current)) return next();  // Known fingerprint

    // Unknown fingerprint: treat it as a risk signal rather than blocking immediately
    req.session.fingerprintAlerts += 1;
    if (isHighRiskOperation(req) || req.session.fingerprintAlerts > THRESHOLD) {
        return res.redirect('/reauth');  // Add it to bound after successful reauthentication
    }
    next();
}
Enter fullscreen mode Exit fullscreen mode

At this point, you may be thinking: “Why use bound.includes(current)? Why not bind the session to one exact value with bound === current?”

Good catch. That question leads directly to the main topic of this article: strict equality can incorrectly reject legitimate users.

JA4 Can Change Even for the Same Client

The FoxIO README includes several JA4 fingerprints produced by the same Chrome client under different conditions.

section a  section b    section c
t13d1516h2_8daaf6152771_02713d6af862   (TCP)
q13d0312h3_55b375c5d22e_06cda9e17597   (QUIC = HTTP/3)
t13d1517h2_8daaf6152771_b0da82dd1658   (pre-shared key = session resumption)
t13d1517h2_8daaf6152771_b1ff8ab2d16f   (no key)
Enter fullscreen mode Exit fullscreen mode

There are at least three sources of variation.

TLS session resumption with a PSK

When a client reconnects using session resumption, the ClientHello includes the pre_shared_key extension. This changes the extension count in the a section and also changes the c section. In the example above, the relevant value changes from 1516 to 1517.

HTTP/2 (h2) versus HTTP/3 (h3)

HTTP/3 runs over QUIC, so the protocol marker changes from t to q, and the a, b, and c sections can all change. Modern browsers can move between HTTP/2 and HTTP/3, so it is normal for a single user to produce more than one family of JA4 fingerprints.

Browser updates

A browser update can change the TLS stack and therefore change the JA4 fingerprint. This may not happen with every release, but over a sufficiently long period it is inevitable.

Testing the Behavior

To verify this behavior, I configured an AWS WAF Count rule to insert ${awswaf:ja4:}, then used a CloudFront Function to return the observed value as JSON. I collected and compared JA4 fingerprints from the same machine under different conditions.

First, I changed the HTTP version while using the same curl binary.

Client JA4
curl --http1.1 t13d3012h1_1d37bd780c83_882d495ac381
curl --http2 t13d3012h2_1d37bd780c83_882d495ac381
curl --http3 q13d0312h3_55b375c5d22e_c0efc70deb60

The HTTP/1.1 and HTTP/2 values are identical except for the ALPN field, h1 versus h2. When the connection switches to HTTP/3 over QUIC, however, the leading transport marker changes from t to q, and all three JA4 sections change.

In other words, even the same curl binary can produce a different fingerprint when the transport path changes.

Next, I forced Chrome to use TCP with HTTP/2 and compared a full TLS handshake with resumed sessions. The procedure was:

  1. Fully quit and restart Chrome.
  2. Access the site once to produce a full handshake.
  3. Open chrome://net-internals/#sockets and select Flush socket pools.
  4. Reload the page to trigger session resumption.
State JA4 Numeric portion of the a section
Full handshake, first request t13d1516h2_8daaf6152771_806a8c22fdea 1516
Session resumption, first attempt t13d1517h2_8daaf6152771_a87ad97598a9 1517
Session resumption, second attempt t13d1517h2_8daaf6152771_a87ad97598a9 1517
Session resumption, third attempt t13d1517h2_8daaf6152771_a87ad97598a9 1517

Even though this was the same browser and the same application session, session resumption changed the numeric value in the a section from 1516 to 1517, and the c section changed as well. This is because the resumed ClientHello contains one additional pre_shared_key extension. The final two digits in that part of the a section represent the extension count.

Interestingly, the transition from t13d1516..._8daaf6152771 to t13d1517..._8daaf6152771 matches the Chrome example in the FoxIO README through both the a and b sections.

These results reveal three facts that directly affect session-binding design.

1. The b section is relatively stable within the same transport family

The b section is the hash of the cipher-suite list. In my tests, it remained unchanged across HTTP/1.1 and HTTP/2 and across full and resumed TCP handshakes: 8daaf6152771 for Chrome and 1d37bd780c83 for curl.

Switching to QUIC can change it because QUIC uses TLS 1.3 and presents a different cipher-suite profile. Even so, the b section was the most stable part of JA4 within a given transport family.

2. The variation converges on a small number of values

For the TCP case, the fingerprint changed once between the full handshake and the first PSK-based resumption, then remained stable on the second and third resumed connections.

This suggests that remembering two values—a full-handshake fingerprint and a resumed-session fingerprint—may be sufficient to recognize that client’s normal TCP behavior.

3. QUIC reduces distinguishing power

QUIC is limited to TLS 1.3 and therefore has fewer cipher-suite combinations. In my measurements, Chrome over HTTP/3 produced a fingerprint beginning with q13d0311h3_55b375c5d22e_..., whose a and b sections were very similar to those produced by curl over HTTP/3.

With HTTP/3, the c section or an additional signal such as JA4H may be necessary to distinguish clients reliably.

Turning the Findings into a Practical Design

Based on these observations, a practical session-binding design looks like this:

  • Do not require one exact JA4 value. Otherwise, legitimate users may be rejected whenever TLS session resumption occurs or the browser switches between HTTP/2 and HTTP/3.
  • Maintain a set of previously observed fingerprints and combine it with a risk score. An unknown value should increase risk rather than cause an immediate block. Require reauthentication only before high-risk operations such as money transfers, password changes, email-address changes, or account deletion.
  • When using a relaxed comparison, compare JA4 section by section. JA4 is designed so that similar client fingerprints remain structurally comparable. For example, you can give greater weight to a matching b section while allowing expected variation in a and c.

Simply being able to notice that a stolen cookie is being used from a different TLS stack is already valuable. Adding too many conditions, on the other hand, can quickly drown the system in false positives. The balance matters.

Step-Up Authentication with ATP Signals

Another authentication-related use case for Dynamic Label Interpolation is AWS WAF Account Takeover Prevention, or ATP.

ATP inspects login requests and emits labels for signals such as compromised credential use and credential-stuffing activity.

Previously, forwarding these signals required a separate rule for each label. With Dynamic Label Interpolation, you can forward the entire namespace instead.

{
  "Name": "forward-atp-signals",
  "Statement": {
    "LabelMatchStatement": {
      "Scope": "NAMESPACE",
      "Key": "awswaf:managed:aws:atp:signal:"
    }
  },
  "Action": {
    "Count": {
      "CustomRequestHandling": {
        "InsertHeaders": [
          {
            "Name": "atp-signals",
            "Value": "${awswaf:managed:aws:atp:signal:}"
          }
        ]
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The application receives the values in x-amzn-waf-atp-signals. If the header contains credential_compromised, for example, the application could allow the login to complete but immediately require the user to reset the password.

This is not simply “blocking with WAF.” It turns WAF detections into inputs for adaptive or step-up authentication, allowing the application to strengthen the authentication flow according to the observed risk.

One caveat: ATP is a paid managed rule group with subscription and request-based charges. You should also confirm the current label names and behavior in the official documentation before deploying this in production.

Conclusion

  • AWS WAF Dynamic Label Interpolation can forward JA4 to the application, allowing a session to be associated with a TLS fingerprint. An attacker may steal a cookie, but reproducing the victim’s TLS fingerprint requires substantially more effort.
  • JA4 can change even for the same client because of TLS session resumption, HTTP/2-to-HTTP/3 switching, and browser updates. Binding a session to one exact JA4 value can therefore reject legitimate users.
  • A set of known fingerprints, a risk score, and reauthentication before high-risk operations appear to be a practical middle ground.
  • ATP detections can be used not only to block requests, but also to drive stronger authentication and account-recovery controls.

In the next article, I will cover the reverse direction: a feedback loop in which the application reports a malicious client back to the edge so that subsequent requests can be blocked there.

Top comments (0)