Pin a fingerprint in CI

A TLS fingerprint is a good regression test for one specific reason: it changes when your TLS stack changes, including every time you did not mean to change it. A patch release of a transitive dependency can move it. Nothing else in your test suite will notice.

The minimal version

/api/ja4 returns the fingerprint as plain text with no JSON to parse, which makes the whole assertion a string comparison.

#!/usr/bin/env bash
set -euo pipefail

EXPECTED="t13d1516h2_8daaf6152771_e5627efa2ab1"
GOT=$(curl -s https://kittens.sh/api/ja4)

if [ "$GOT" != "$EXPECTED" ]; then
  echo "TLS fingerprint drifted" >&2
  echo "  expected: $EXPECTED" >&2
  echo "  got:      $GOT" >&2
  exit 1
fi

Replace curl with whatever client you actually ship — the point is to measure the client under test, not the one your CI image happens to have.

Make the failure readable

A diff of two hashes tells you something broke and nothing about what. /api/ja4/raw returns the fingerprint with both hashed halves expanded into the lists they were built from, so the failure output points at the cipher or extension that moved.

curl -s https://kittens.sh/api/ja4/raw > got.txt
diff -u expected.txt got.txt || {
  echo "handshake changed — review before updating the fixture" >&2
  exit 1
}

Commit expected.txt. When it legitimately changes, the diff in the pull request is the review.

GitHub Actions

name: fingerprint
on: [push, pull_request]

jobs:
  ja4:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Assert the handshake has not drifted
        run: |
          GOT=$(curl -s https://kittens.sh/api/ja4)
          echo "ja4: $GOT"
          test "$GOT" = "$(cat .github/expected-ja4.txt)"

pytest

import httpx
import pytest

EXPECTED_JA4 = "t13d1516h2_8daaf6152771_e5627efa2ab1"


@pytest.mark.network
def test_client_fingerprint_has_not_drifted(client: httpx.Client):
    fp = client.get("https://kittens.sh/api/clean").json()
    assert fp["ja4"] == EXPECTED_JA4, (
        f"handshake changed: {fp['ja4']}. "
        "If this was intentional, update the fixture."
    )

Pass the same configured client your application uses. A fixture that builds a fresh default client tests the default, which is not the thing you are trying to protect.

Four things that will bite

Do not pin a browser JA3. Chrome randomises its extension order, so its JA3 differs on every connection and the test fails immediately and forever. Pin JA4, which sorts before hashing. Scripted clients that do not randomise are safe to pin either way.

Pin the HTTP/2 fingerprint too, separately. akamai_hash comes from a different layer and moves independently. A client can keep its JA4 across an upgrade and change its SETTINGS frame.

The network is a dependency. This assertion needs egress, and it measures whatever finally reaches the origin — so a CI proxy that terminates TLS will be what gets fingerprinted. Mark the test so it can be skipped where there is no direct egress rather than letting it fail confusingly.

A red build is information, not a bug. The fingerprint changing means your TLS stack changed. Sometimes that is a deliberate upgrade and the fixture should be updated in the same commit. The value is in being told, rather than finding out from production traffic weeks later.

Related

Verifying an impersonation library covers getting the target fingerprint in the first place, and the API reference lists every endpoint.