Skip to main content

JSON Preset Spec

The canonical JSON schema for presets. This is the contract for any preset built programmatically or shipped as a file.

Source of truth: fingerprint/custom_preset.go, where PresetSpec and the surrounding types are defined. The schema is round-trip stable. fingerprint.Describe(name) produces JSON that fingerprint.LoadPresetFromJSON parses back into an identical preset.

note

JSON doesn't allow comments. The // ... annotations in the snippets below are docs only. Strip them before handing the JSON to the parser.


Top-level shape​

The outer document carries a schema version and either a single preset or a pool.

{
"version": 1,
"preset": { ... },
"pool": { ... }
}
FieldTypeRequiredNotes
versionintyesSchema version. Currently 1.
presetobjectone of preset / poolSingle preset definition.
poolobjectone of preset / poolA pool of presets for round-robin or random rotation.

Exactly one of preset or pool has to be set.

Pool shape​

Pools wrap a list of preset definitions plus a rotation strategy. The runtime picks one preset per session.

{
"version": 1,
"pool": {
"name": "my-rotation",
"strategy": "random", // or "round-robin"
"presets": [
{ "name": "...", ... },
{ "name": "...", ... }
]
}
}

preset object​

The full set of fields a single preset can declare. Each section corresponds to one layer of the wire fingerprint.

{
"name": "my-chrome", // required, unique
"based_on": "chrome-152-windows", // optional, parent preset name
"tls": { ... }, // TLS fingerprint
"http2": { ... }, // HTTP/2 fingerprint
"http3": { ... }, // HTTP/3 + QUIC fingerprint
"headers": { ... }, // user-agent, header values, header order
"tcp": { ... }, // TCP/IP fingerprint
"protocols":{ ... } // protocol support flags
}
FieldTypeNotes
namestringThe registry name. Used by NewSession(name).
based_onstringParent preset. Inherits everything; this preset's fields overlay. Inheritance loops are detected at build time (looped chains return an error).
tlsobjectSee TLS section.
http2objectSee HTTP/2 section.
http3objectSee HTTP/3 section.
headersobjectSee Headers section.
tcpobjectSee TCP section.
protocolsobjectSee Protocols section.

Omit a field and it inherits from based_on. With no parent, the field stays at its zero value.


tls object​

The TLS layer. Two configuration modes that don't mix: a named uTLS ClientHello (client_hello) or a raw JA3 string. The named mode covers Chrome, Firefox, Safari, and iOS variants tracked in uTLS; JA3 mode is for anything outside that set.

"tls": {
"client_hello": "chrome-152-windows", // mutually exclusive with ja3
"psk_client_hello": "chrome-152-windows-psk",
"quic_client_hello": "chrome-148-quic",
"quic_psk_client_hello": "chrome-148-quic-psk",

"ja3": "771,4865-...,0-23-...,29-23-24,0",
"ja3_extras": { ... },

"signature_algorithms": [1027, 2052, 1025],
"delegated_credential_algorithms": [1027, 2052],
"alpn": ["h2", "http/1.1"],
"cert_compression": ["brotli", "zlib", "zstd"],
"permute_extensions": true,
"record_size_limit": 16385,
"key_share_curves": 1
}
FieldTypeNotes
client_hellostringuTLS ClientHello ID name (e.g. "chrome-152-windows"). Mutually exclusive with ja3.
psk_client_hellostringPSK variant for TLS session resumption. Requires client_hello (directly or via based_on).
quic_client_hellostringQUIC-specific ClientHello. Cannot be used with ja3.
quic_psk_client_hellostringQUIC PSK variant.
ja3stringFull JA3: Version,Ciphers,Extensions,Curves,Formats. Setting this clears any inherited client_hello.
ja3_extrasobjectJA3-mode extras: sig-algs, ALPN, cert compression, etc. Only valid when ja3 is set.
signature_algorithmsuint16[]Signature-algorithm override for TCP (HTTP/1.1 and HTTP/2). Replaces the extension on top of whatever base the ClientHello produces, so it works with based_on and no ja3, which is exactly how the shipped Chrome 150/151 profiles add their post-quantum entries. Inherited by presets that derive from this one.
quic_signature_algorithmsuint16[]The HTTP/3 counterpart. Separate because a browser's QUIC list can differ from its TCP one: Chrome 150 and 151 send post-quantum signature algorithms over TCP but not over QUIC. Leave unset to inherit the base.
delegated_credential_algorithmsuint16[]Top-level shortcut. JA3 mode only.
alpnstring[]ALPN protocol list. Default ["h2", "http/1.1"]. JA3 mode only.
cert_compressionstring[]One or more of "brotli", "zlib", "zstd". JA3 mode only.
permute_extensionsboolWhen true, extension order shuffles per handshake (Chrome 110+ behaviour).
record_size_limituint16TLS extension 28 value.
key_share_curvesintNumber of curves to advertise key shares for. 1 for Chrome (X25519MLKEM768 only), 3 for Firefox.

Captured ClientHello mode​

A third way to describe the handshake, alongside client_hello and ja3: hand over the bytes of a real one.

"tls": {
"raw_client_hello": "FgMBAgA...",
"raw_psk_client_hello": "FgMBAiA...",
"allow_blunt_mimicry": true,
"permute_raw_hello": true
}
FieldTypeMeaning
raw_client_hellostringBase64 of a captured ClientHello, starting at the TLS record header. Everything it contains goes on the wire as captured.
raw_psk_client_hellostringThe same client's resumption-shaped hello. Without it a resuming session sends a first-handshake shape with a ticket bolted on, which that client never sends.
allow_blunt_mimicryboolPasses through extensions this library has no model for. Without it an unmodelled extension is a load-time error, which is how a captured curl hello fails.
permute_raw_helloboolShuffles the extension order per connection.

permute_raw_hello has to be declared because it cannot be detected. A capture is one connection, so its extension order is a single sample, and nothing in the bytes says whether that client would have ordered them differently next time. Chromium reshuffles on every handshake; NSS, Apple's stack and Go do not. Leave it off for a captured curl or Firefox, turn it on for a captured Chrome, or the preset either freezes an order the real client varies or invents variation the real client never has.

It is ignored when allow_blunt_mimicry is set. That mode exists to pass through extensions with no model behind them, and moving something the library cannot parse risks putting it where the protocol does not allow.

Captures are validated when the preset loads, so an unusable one is a named error rather than a handshake failure on the first request.

trust_anchors​

"tls": { "trust_anchors": ["82df130201", "839a648c9b2d0107"] }

Hex-encoded certificate authority identifiers for the trust anchors extension (0xCA34), 1 to 255 bytes each. Chrome 152 advertises 28 of them. The order is reshuffled per connection, because the browser's own order is not stable: it comes from iterating a hash container that is reseeded whenever the configuration is copied, which happens once per connection.

psk_ja3​

The JA3-mode counterpart of psk_client_hello: the resumption-shaped JA3 string for the same client. Without it a JA3 preset cannot resume, because a first capture never contains extension 41.

ja3_extras shape​

The same fields as the top-level shortcuts, nested under one object. Use this form when you want the JA3 string and its extras kept together as a single block.

"ja3_extras": {
"signature_algorithms": [1027, 2052, ...],
"delegated_credential_algorithms": [1027, 2052, ...],
"alpn": ["h2", "http/1.1"],
"cert_compression": ["brotli"],
"permute_extensions": true,
"record_size_limit": 16385,
"key_share_curves": 1
}

TLS validation rules​

The build step rejects the following combinations:

  • ja3 and client_hello set in the same spec.
  • ja3_extras without ja3.
  • Any of psk_client_hello, quic_client_hello, quic_psk_client_hello when there's no primary client_hello or ja3 to anchor them.
  • quic_client_hello / quic_psk_client_hello / psk_client_hello paired with ja3 (JA3 doesn't control QUIC TLS, use client_hello mode for QUIC).
  • TLS extension fields (signature_algorithms, alpn, cert_compression, permute_extensions, record_size_limit) when client_hello is set without ja3 (those fields only apply to JA3).

http2 object​

The HTTP/2 layer. Covers SETTINGS values and order, WINDOW_UPDATE size, pseudo-header order, HPACK indexing, and the per-resource priority table.

"http2": {
"akamai": "1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",

"header_table_size": 65536,
"enable_push": false,
"max_concurrent_streams": 0,
"initial_window_size": 6291456,
"max_frame_size": 0,
"max_header_list_size": 262144,
"connection_window_update": 15663105,
"stream_weight": 256,
"stream_exclusive": true,
"no_rfc7540_priorities": false,

"settings": [{"id": 1, "value": 65536}, ...],
"settings_order": [1, 2, 4, 6],
"pseudo_order": ["m", "a", "s", "p"],

"hpack_header_order": ["sec-ch-ua", "user-agent", ...],
"hpack_indexing_policy":"chrome",
"hpack_never_index": ["cookie", "authorization"],
"stream_priority_mode": "chrome",
"disable_cookie_split": false,

"priority_table": {
"document": { "urgency": 0, "incremental": false, "emit_header": true },
"image": { "urgency": 5, "incremental": true, "emit_header": true }
}
}

Akamai shorthand​

akamai is a one-line shorthand for the four parts of an Akamai HTTP/2 fingerprint: SETTINGS|WINDOW_UPDATE|PRIORITY|PSEUDO_ORDER. The parser splits it and applies each part to the corresponding fields.

When both akamai and individual fields are set, resolution runs in this order:

  1. Apply individual fields (header_table_size, enable_push, etc.) for any slots the akamai shorthand does not touch.
  2. Apply akamai authoritatively for the slots it explicitly names.
  3. Apply settings (the structured [{id, value}] list) last. Overrides both.

So with akamai: "1:65536" and header_table_size: 99999, the akamai value wins for slot 1. Slots the akamai string doesn't name (like max_concurrent_streams) take the individual value.

Settings IDs​

The numeric IDs HTTP/2 uses in SETTINGS frames.

IDSetting
1HEADER_TABLE_SIZE
2ENABLE_PUSH
3MAX_CONCURRENT_STREAMS
4INITIAL_WINDOW_SIZE
5MAX_FRAME_SIZE
6MAX_HEADER_LIST_SIZE
9NO_RFC7540_PRIORITIES

HPACK and priority​

How header compression and stream priorities behave on the wire.

FieldTypeValues
hpack_indexing_policystring"chrome", "never", "always", "default"
stream_priority_modestring"chrome", "default"
disable_cookie_splitboolWhen true, the Cookie: header is sent as one line instead of split into multiple HPACK entries.
hpack_never_indexstring[]Lowercase header names that must be sent without HPACK indexing.

hpack_header_order_subresource​

A second header order, used for every request that is not a top-level navigation.

"http2": {
"hpack_header_order": ["sec-ch-ua", "sec-ch-ua-mobile", "..."],
"hpack_header_order_subresource": ["sec-ch-ua-platform", "user-agent", "..."]
}

Chrome builds a navigation and a subresource through different code paths and the leading block comes out differently. A navigation leads with the three low-entropy client hints, then upgrade-insecure-requests, then user-agent. A subresource leads with the platform hint and user-agent, then the other two hints, carries a Referer, and sends neither upgrade-insecure-requests nor sec-fetch-user.

The transport picks between the two using the request's sec-fetch-dest, the same field priority_table keys off. document and iframe take the navigation order; everything else takes this one. Omit the field and a preset uses hpack_header_order for both, which is what a client with one order wants.

Measured across script, style, image, font, manifest and fetch() requests, all six produce a single order, so this is a two-way split rather than one order per resource type.

Other http2 fields​

FieldTypeMeaning
hpack_representationobjectPer-header-name override of the HPACK representation: "incremental", "without", "never" or "default". Deltas only; browser profiles leave it empty.
data_frame_max_sizeuint32Caps the DATA frame payload this profile sends. Omitted derives it from the client family: 16375 for Chrome, the peer's advertised size otherwise. Chrome's cap exists so header plus payload occupy exactly one TLS record.
preface_ping_idle_msuint32When a connection has been idle this long, the next request on it carries a PING alongside. Omitted or 0 sends none. Chrome uses 10000.
preface_ping_hang_msuint32The companion hang interval.
idle_ping_msuint32Enables a periodic health-check PING, which no browser sends. Omitted or 0 means off, which is what a browser profile wants.

priority_table​

Maps sec-fetch-dest values (document, image, script, style, font, and so on) to per-resource priority settings. When populated, the transport emits a per-request RFC 7540 stream weight derived from urgency plus an RFC 9218 priority: header on every request, keyed off the request's sec-fetch-dest.

"priority_table": {
"document": { "urgency": 0, "incremental": false, "emit_header": true },
"image": { "urgency": 5, "incremental": true, "emit_header": true },
"style": { "urgency": 1, "incremental": false, "emit_header": true }
}
FieldTypeNotes
urgencyuint80 (highest) to 7 (lowest). Maps to RFC 9218.
incrementalboolWhether the resource can be processed incrementally.
emit_headerboolWhen true, the transport emits a priority: header on the request.

Omit it and every request uses the preset's static stream_weight and stream_exclusive. That's the legacy single-weight behaviour from before the priority table existed.


http3 object​

The HTTP/3 layer, including the QUIC transport parameters that anchor a Chrome-shaped initial packet.

"http3": {
"qpack_max_table_capacity": 65536,
"qpack_blocked_streams": 100,
"max_field_section_size": 65536,
"enable_datagrams": true,

"quic_initial_packet_size": 1252,
"quic_max_incoming_streams": 100,
"quic_max_incoming_uni_streams":3,
"quic_allow_0rtt": true,
"quic_chrome_style_initial": true,
"quic_disable_hello_scramble": false,
"quic_transport_param_order": "chrome", // or "random"
"quic_connection_id_length": 8,
"quic_max_datagram_frame_size": 65535,

"max_response_header_bytes": 524288,
"send_grease_frames": true,

"quic_initial_stream_receive_window": 2097152,
"quic_initial_connection_receive_window": 16777216
}
FieldTypeNotes
qpack_max_table_capacityuint64QPACK encoder table cap advertised in SETTINGS.
qpack_blocked_streamsuint64Max QPACK-blocked streams.
max_field_section_sizeuint64Max headers size.
enable_datagramsboolWhether to advertise H3 DATAGRAM support.
quic_initial_packet_sizeuint16Initial packet size for QUIC handshake. Chrome uses 1252.
quic_max_incoming_streamsint64initial_max_streams_bidi.
quic_max_incoming_uni_streamsint64initial_max_streams_uni.
quic_allow_0rttboolEnable 0-RTT data.
quic_chrome_style_initialboolMimic Chrome's first-flight packet shape.
quic_disable_hello_scrambleboolWhen true, don't permute extensions in QUIC ClientHello.
quic_transport_param_orderstring"chrome" or "random". Chrome's order is fixed and identifying.
quic_connection_id_lengthintLength of source connection IDs.
quic_max_datagram_frame_sizeuint64Max DATAGRAM frame size.
max_response_header_bytesuint64Per-response header size cap.
send_grease_framesboolSend GREASE frames between real frames.
quic_initial_stream_receive_windowuint64initial_max_stream_data_*. iOS Safari uses 2 MiB; Chrome desktop uses different values.
quic_initial_connection_receive_windowuint64initial_max_data. iOS Safari uses 16 MiB.

Omit a field (nil) and the quic-go default applies. The library only writes a slot when the spec asks for it.


quic_connection_options​

"http3": { "quic_connection_options": ["ORIG"] }

The Google connection-option tags sent in the QUIC transport parameters. Chrome currently sends ["ORIG"], and this is a preset key rather than a constant because the value comes from a server-side experiment and can change without a Chrome release. An explicitly empty list sends none; omitting the field entirely falls back to the default.

headers object​

The HTTP request header bundle. User-Agent, all the named values, and the exact wire order they're sent in.

"headers": {
"user_agent": "Mozilla/5.0 ...",
"values": {
"accept-language": "en-US,en;q=0.9",
"sec-ch-ua": "..."
},
"order": [
{"key": "sec-ch-ua", "value": "..."},
{"key": "user-agent", "value": ""},
{"key": "accept", "value": "..."},
{"key": "accept-encoding", "value": "gzip, deflate, br, zstd"}
]
}
FieldTypeNotes
user_agentstringThe User-Agent value. Set separately because the field is also referenced in order via "key": "user-agent".
valuesobject (string→string)Header values keyed by lowercase header name. Merged with the inherited values from based_on.
orderarray of {key, value}The exact header order on the wire. Lowercase keys. An empty value means "use the value from values or user_agent".

Order matters. HTTP/2 and HTTP/3 don't enforce header order on the receiving side, but bot-detection products fingerprint it. Real Chrome and real Firefox sit far apart on this dimension.


client_hints object​

The high-entropy UA client hints, sent only after an origin asks for them with Accept-CH. Top level, alongside headers, since they are a separate negotiation rather than part of the default header block.

"client_hints": {
"full_version_list": "\"Chromium\";v=\"152.0.7977.64\", ...",
"platform_version": "19.0.0",
"arch": "x86",
"bitness": "64",
"model": "",
"wow64": "?0"
}
FieldHeader
full_version_listsec-ch-ua-full-version-list
platform_versionsec-ch-ua-platform-version
archsec-ch-ua-arch
bitnesssec-ch-ua-bitness
modelsec-ch-ua-model
wow64sec-ch-ua-wow64

full_version_list is worth taking from a real capture rather than composing: it carries the exact build number, which cannot be derived from the major version. model is empty on desktop and set on Android.

tcp object​

The TCP/IP layer fingerprint. TTL, MSS, window size and scale, the Don't Fragment bit.

"tcp": {
"platform": "Windows", // shorthand: "Windows", "macOS", "Linux"
"ttl": 128,
"mss": 1460,
"window_size": 65535,
"window_scale": 8,
"df_bit": true
}

platform is a shorthand that fills in the typical TTL / MSS / window combo for that OS. Individual fields override the platform default field-by-field.

These only matter for the handful of bot-management products that fingerprint the TCP/IP stack. Most don't.


protocols object​

Feature flags that gate which protocols the preset participates in.

"protocols": {
"http3": true
}
FieldTypeNotes
http3boolWhether the preset advertises HTTP/3 support. When false, the runtime won't try QUIC even if the host advertises it via Alt-Svc.
h3boolAlias for http3.
h2boolWhen false, auto mode skips HTTP/2 and goes straight to HTTP/1.1.

Strict loading​

The loader ignores keys it does not recognise. That is right for forward compatibility, so a preset written for a newer version still loads on an older one, and wrong for a typo.

It is also the quietest failure the loader has. A misspelled key does not fail; it leaves that part of the preset at whatever the inheritance chain supplied. A file written to mirror one client can go on the wire as another, with no error anywhere. Every other kind of mistake announces itself.

Two functions cover it:

  • UnknownPresetFields(data) returns every key nothing models, as dotted paths like preset.tls.cipher_suits. It walks the whole document rather than stopping at the first one, so a run reports every typo at once.
  • LoadPresetFromJSONStrict(data) refuses such a preset outright, naming the keys.

Use the strict form when a preset is meant to reproduce a specific client exactly and a silently ignored key would mean shipping the wrong fingerprint. The lenient LoadPresetFromJSON stays the default.

Free-form maps end the walk, since keys under them are your data rather than schema: custom header names and hpack_representation entries are never reported as unknown fields. Every preset shipped with the library passes strict loading.

Round-trip guarantee​

The chain Describe -> LoadPresetFromJSON -> BuildPreset -> Describe produces byte-identical JSON. CI uses this property to catch silent drift in the embedded presets.

import "github.com/sardanioss/httpcloak/fingerprint"

orig, _ := fingerprint.Describe("chrome-152-windows")
pf, _ := fingerprint.LoadPresetFromJSON([]byte(orig))
rebuilt, _ := fingerprint.BuildPreset(pf.Preset)
fingerprint.Register(rebuilt.Name+"-rt", rebuilt)
again, _ := fingerprint.Describe(rebuilt.Name+"-rt")
// orig == again, modulo the renamed `name` field

The verified spot-check (chrome-152-windows, firefox-148, safari-18-ios) shows zero diff beyond the rename.


Inheritance and validation​

based_on resolves at build time. Inheritance loops are caught and reported as based_on inheritance loop detected at "...". The chain terminates at a built-in preset whose based_on is empty.

BuildPreset(spec) does the following:

  1. If based_on is set, the parent preset gets cloned (deep copy of headers, H2/H3 config, JA3 extras).
  2. Each non-empty section in your spec overlays on top.
  3. Validation runs: TLS rules, HPACK indexing policy values, stream priority mode values, QUIC transport param order values.
  4. The built *Preset comes back. Register it with fingerprint.Register(name, preset) so NewSession(name) can find it.

A spec with no name is fine. BuildPreset returns a *Preset carrying whatever name based_on had, and you can rename it before registering.


Loading from disk​

Two-step: read and parse the JSON, then build and register the preset.

pf, err := fingerprint.LoadPresetFromFile("/etc/httpcloak/presets/my-chrome.json")
preset, err := fingerprint.BuildPreset(pf.Preset)
fingerprint.Register("my-chrome", preset)

// Now NewSession("my-chrome") works.

Or, in one shot:

preset, err := fingerprint.LoadAndBuildPreset("/path/to/preset.json")
fingerprint.Register(preset.Name, preset)

A complete minimal example​

A real preset that swaps only the User-Agent on top of chrome-152-windows:

{
"version": 1,
"preset": {
"name": "chrome-152-windows-headless",
"based_on": "chrome-152-windows",
"headers": {
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/148.0.0.0 Safari/537.36"
}
}
}

Everything else (TLS, HTTP/2, header order, HTTP/3, TCP) inherits from chrome-148-windows. The embedded JSONs use this same pattern to ship Chrome 147 and 148 without retyping 5000 lines per version.