TLS Fingerprinting
TLS fingerprinting is the primary method anti-bot systems use to distinguish automated HTTP clients from real browsers. When a TLS connection is established, the client sends a ClientHello message containing cipher suites, signature algorithms, ECDH curves, and extensions in a specific order. This produces a hash (JA3 or JA4 fingerprint) that uniquely identifies the TLS library. Node.js (OpenSSL) and Chrome (BoringSSL) produce different fingerprints even when configured with identical cipher suites.
The TlsFingerprint Interface
Each browser profile contains a TlsFingerprint object that defines the exact TLS parameters:
interface TlsFingerprint {
/** TLS cipher suites in exact browser order, OpenSSL names, colon-separated */
ciphers: string;
/** Signature algorithms in exact browser order, colon-separated */
sigalgs: string;
/** ECDH curves / supported groups in exact browser order */
ecdhCurve: string;
/** Minimum TLS version */
minVersion: 'TLSv1.2' | 'TLSv1.3';
/** Maximum TLS version */
maxVersion: 'TLSv1.2' | 'TLSv1.3';
/** ALPN protocols in browser order */
alpnProtocols: string[];
/** TLS session timeout in seconds */
sessionTimeout: number;
} createSecureContext()
Creates a tls.SecureContext from a browser profile’s TLS fingerprint. This is the low-level function that configures the cipher suite order, signature algorithms, and ECDH curves:
import { createSecureContext } from 'rezo/stealth';
const profile = getProfile('chrome-131');
const ctx = createSecureContext(profile.tls);
// Use with an HTTPS agent
import https from 'node:https';
const agent = new https.Agent({ secureContext: ctx }); The function maps fingerprint fields directly to tls.createSecureContext() options:
export function createSecureContext(fingerprint: TlsFingerprint): tls.SecureContext {
return tls.createSecureContext({
ciphers: fingerprint.ciphers,
sigalgs: fingerprint.sigalgs,
ecdhCurve: fingerprint.ecdhCurve,
minVersion: fingerprint.minVersion,
maxVersion: fingerprint.maxVersion,
sessionTimeout: fingerprint.sessionTimeout,
});
} buildTlsOptions()
Builds complete TLS connection options suitable for tls.connect(), https.Agent, or http2.connect():
import { buildTlsOptions, getProfile } from 'rezo/stealth';
const profile = getProfile('firefox-133');
const tlsOpts = buildTlsOptions(profile.tls);
// tlsOpts contains:
// {
// secureContext: <configured SecureContext>,
// ALPNProtocols: ['h2', 'http/1.1'],
// minVersion: 'TLSv1.2',
// maxVersion: 'TLSv1.3',
// rejectUnauthorized: true
// } When used with the HTTP adapter, these options are passed to the underlying https.Agent so that every HTTPS connection uses the browser’s TLS fingerprint.
Per-Browser Cipher Differences
Each browser engine uses a different TLS library with its own default cipher suite list:
Chrome / Edge / Opera / Brave (BoringSSL)
BoringSSL produces the most common cipher order seen in web traffic. Chrome’s cipher list starts with TLS 1.3 suites, followed by ECDHE+AESGCM, then ECDHE+CHACHA20:
TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:
ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:
ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:
ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:... Firefox (NSS)
NSS places ChaCha20 before AES-GCM in some positions and uses a different extension ordering:
TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_256_GCM_SHA384:
ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:
ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:... Safari (Apple TLS / SecureTransport)
Safari has its own unique cipher ordering that reflects Apple’s security preferences:
TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:
ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:
ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-AES256-GCM-SHA384:... Overriding TLS Parameters
You can override specific TLS parameters through RezoStealthOptions while keeping the rest of the profile intact:
import { Rezo } from 'rezo';
import { RezoStealth } from 'rezo/stealth';
const rezo = new Rezo({
stealth: new RezoStealth({
profile: 'chrome-131',
tls: {
// Force TLS 1.3 only
minVersion: 'TLSv1.3',
maxVersion: 'TLSv1.3',
// Override ALPN to HTTP/1.1 only
alpnProtocols: ['http/1.1']
}
})
}); Node.js Limitation (OpenSSL)
Node.js uses OpenSSL for its TLS implementation. Even when you configure the exact same cipher suites as Chrome, the resulting JA3/JA4 fingerprint will not match because:
- Extension ordering — OpenSSL sends TLS extensions in a different order than BoringSSL
- GREASE values — Chrome injects random GREASE (Generate Random Extensions And Sustain Extensibility) values; OpenSSL does not
- Compression methods — Different default compression method listings
- Supported groups encoding — Subtle differences in how supported groups are advertised
Sites with sophisticated TLS fingerprinting (Cloudflare, Akamai, DataDome, PerimeterX) will detect this mismatch regardless of cipher configuration.
Workaround: curl-impersonate
For production stealth on Node.js against advanced anti-bot systems, use curl-impersonate through Rezo’s cURL adapter. curl-impersonate is a patched version of cURL that uses BoringSSL and exactly replicates Chrome’s TLS behavior, including GREASE values and extension ordering.
Bun Advantage (BoringSSL)
Bun uses BoringSSL as its TLS library — the same library Chrome uses. This means Bun can natively produce a Chrome-like JA3/JA4 fingerprint without any workarounds. When running Rezo with stealth on Bun, the TLS fingerprint is virtually identical to a real Chrome browser.
# Run your stealth script with Bun for native Chrome-like TLS
bun run crawl.ts This makes Bun the preferred runtime for stealth HTTP requests that need to pass TLS fingerprinting checks.