Cloudflare's JavaScript Detection (JSD) challenge quietly runs in the background on protected websites. It checks if your browser can execute JavaScript and fingerprints your environment to detect bots.
Unlike Turnstile captchas or the "I'm Under Attack" page, JSD operates invisibly. Building a JSD solver lets you programmatically obtain the cf_clearance cookie without spinning up headless browsers.
In this guide, you'll learn how to build a JSD solver in Go from scratch. We'll cover extracting challenge parameters, deobfuscating scripts, and posting the oneshot payload.
What is a JSD Solver?
A JSD solver bypasses Cloudflare's JavaScript Detection engine by mimicking what a browser does when it encounters a protected page. The solver extracts challenge parameters from the HTML response, downloads and parses the JSD script, then submits a crafted payload to obtain a valid cf_clearance cookie.
The key difference between JSD and other Cloudflare protections is the "oneshot" nature. You don't solve puzzles or wait for visual captchas. Instead, you submit browser fingerprint data that Cloudflare validates server-side.
JSD works through three main steps. First, Cloudflare injects a script tag setting __CF$cv$params with r (ray ID) and t (timestamp) values. Second, the browser fetches and executes the JSD script from /cdn-cgi/challenge-platform/scripts/jsd/main.js. Third, the script collects fingerprint data and POSTs it back to Cloudflare.
A proper solver replicates this flow without a browser.
Prerequisites and Project Setup
Before building your JSD solver, you need a Go development environment. Install Go 1.21 or later from the official website.
Create a new project directory and initialize the Go module:
mkdir jsd-solver
cd jsd-solver
go mod init github.com/yourusername/jsd-solver
Install the required dependencies:
go get github.com/andybalholm/brotli
go get github.com/Danny-Dasilva/fhttp
go get github.com/refraction-networking/utls
The fhttp library provides HTTP/2 fingerprint spoofing. Standard Go net/http exposes detectable patterns that Cloudflare blocks. Using fhttp with utls makes your requests appear like genuine Chrome traffic.
The brotli package handles decompressing responses. Many Cloudflare responses use Brotli compression.
Understanding the JSD Challenge Flow
When you visit a JSD-protected site, Cloudflare injects an inline script near the closing </body> tag. This script looks something like:
(function(){
window['__CF$cv$params'] = {
r: '8f7e3a2b1c9d0e5f',
t: 'MTcwMjU4NjQ0Ny4wMDAwMDA=',
m: 'encoded_data_here',
s: [0x47f70c9c22, 0xeb16087225]
}
})();
The r parameter is typically the CF-Ray header value (first part before the hyphen). The t parameter is a base64-encoded timestamp. Both values are required to construct the oneshot payload.
After setting these parameters, Cloudflare loads the main JSD script. This script collects browser fingerprint data including:
- Screen resolution and color depth
- Timezone offset
- Plugin information
- WebGL renderer strings
- Canvas fingerprint
- Audio context fingerprint
- Navigator properties
The collected data gets compressed and encoded before being POSTed to Cloudflare's challenge endpoint.
Step 1: Build the HTTP Client with TLS Fingerprinting
Standard HTTP clients get flagged by Cloudflare because their TLS handshake reveals they're not real browsers. You need a client that mimics Chrome's JA3 fingerprint.
Create a file called client.go:
package solver
import (
"crypto/tls"
"net/http"
"time"
fhttp "github.com/Danny-Dasilva/fhttp"
utls "github.com/refraction-networking/utls"
)
type Client struct {
httpClient *fhttp.Client
userAgent string
}
func NewClient() *Client {
transport := &fhttp.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
ForceAttemptHTTP2: true,
}
return &Client{
httpClient: &fhttp.Client{
Transport: transport,
Timeout: 30 * time.Second,
},
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
}
}
This creates a basic client structure. The fhttp library handles HTTP/2 fingerprinting automatically.
Now add a method to make requests with proper headers:
func (c *Client) Get(url string, cookies []*http.Cookie) (*fhttp.Response, error) {
req, err := fhttp.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
c.setHeaders(req)
for _, cookie := range cookies {
req.AddCookie(cookie)
}
return c.httpClient.Do(req)
}
func (c *Client) setHeaders(req *fhttp.Request) {
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
req.Header.Set("Accept-Encoding", "gzip, deflate, br")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Upgrade-Insecure-Requests", "1")
req.Header.Set("Sec-Fetch-Dest", "document")
req.Header.Set("Sec-Fetch-Mode", "navigate")
req.Header.Set("Sec-Fetch-Site", "none")
req.Header.Set("Sec-Fetch-User", "?1")
}
The header order matters. Cloudflare checks not just what headers you send, but the order you send them in. Chrome sends headers in a specific sequence that differs from other browsers.
Step 2: Extract Challenge Parameters from HTML
The next step involves parsing the target page's HTML to extract __CF$cv$params. Create a file called extractor.go:
package solver
import (
"errors"
"regexp"
"strings"
)
type ChallengeParams struct {
R string
T string
M string
ScriptURL string
}
func ExtractParams(html string) (*ChallengeParams, error) {
params := &ChallengeParams{}
// Extract __CF$cv$params object
pattern := regexp.MustCompile(`window\['__CF\$cv\$params'\]\s*=\s*\{([^}]+)\}`)
matches := pattern.FindStringSubmatch(html)
if len(matches) < 2 {
return nil, errors.New("could not find __CF$cv$params in HTML")
}
paramsStr := matches[1]
// Extract individual parameters
params.R = extractValue(paramsStr, "r")
params.T = extractValue(paramsStr, "t")
params.M = extractValue(paramsStr, "m")
if params.R == "" {
return nil, errors.New("could not extract 'r' parameter")
}
// Extract JSD script URL
scriptPattern := regexp.MustCompile(`src="(/cdn-cgi/challenge-platform/scripts/jsd/[^"]+)"`)
scriptMatches := scriptPattern.FindStringSubmatch(html)
if len(scriptMatches) >= 2 {
params.ScriptURL = scriptMatches[1]
} else {
// Default fallback
params.ScriptURL = "/cdn-cgi/challenge-platform/scripts/jsd/main.js"
}
return params, nil
}
The extraction function uses regex to find the parameter object. Add the helper function to parse individual values:
func extractValue(paramsStr, key string) string {
pattern := regexp.MustCompile(key + `\s*:\s*['"]([^'"]+)['"]`)
matches := pattern.FindStringSubmatch(paramsStr)
if len(matches) >= 2 {
return matches[1]
}
return ""
}
Sometimes the r parameter comes from the CF-Ray header instead of the HTML. Add a fallback extraction:
func ExtractRayFromHeader(cfRay string) string {
if cfRay == "" {
return ""
}
parts := strings.Split(cfRay, "-")
if len(parts) > 0 {
return parts[0]
}
return ""
}
The CF-Ray header format is typically ray_id-datacenter, like 8f7e3a2b1c9d0e5f-IAD. You only need the first part.
Step 3: Download and Parse the JSD Script
The JSD script contains obfuscated JavaScript that collects fingerprint data. You don't need to fully deobfuscate it, but you need to extract specific values.
Create a file called script_parser.go:
package solver
import (
"errors"
"io"
"regexp"
"strings"
"github.com/andybalholm/brotli"
)
type ScriptData struct {
FlowToken string
SValue string
Endpoint string
}
func (c *Client) FetchScript(baseURL, scriptPath string) (string, error) {
url := baseURL + scriptPath
req, err := fhttp.NewRequest("GET", url, nil)
if err != nil {
return "", err
}
c.setHeaders(req)
req.Header.Set("Sec-Fetch-Dest", "script")
req.Header.Set("Sec-Fetch-Mode", "no-cors")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var body []byte
if resp.Header.Get("Content-Encoding") == "br" {
reader := brotli.NewReader(resp.Body)
body, err = io.ReadAll(reader)
} else {
body, err = io.ReadAll(resp.Body)
}
if err != nil {
return "", err
}
return string(body), nil
}
The script content is heavily obfuscated. Cloudflare uses variable name randomization, string encoding, and control flow flattening. However, certain patterns remain consistent.
Add the parsing logic:
func ParseScript(scriptContent string) (*ScriptData, error) {
data := &ScriptData{}
// Extract the flow token (used in the endpoint URL)
flowPattern := regexp.MustCompile(`/flow/ov1/([a-zA-Z0-9]+)/`)
flowMatches := flowPattern.FindStringSubmatch(scriptContent)
if len(flowMatches) >= 2 {
data.FlowToken = flowMatches[1]
}
// Extract the challenge endpoint pattern
endpointPattern := regexp.MustCompile(`challenge-platform/h/[bg]/`)
if endpointPattern.MatchString(scriptContent) {
if strings.Contains(scriptContent, "/h/b/") {
data.Endpoint = "/cdn-cgi/challenge-platform/h/b/jsd/oneshot"
} else {
data.Endpoint = "/cdn-cgi/challenge-platform/h/g/jsd/oneshot"
}
}
// Extract s values (seed values for fingerprint)
sPattern := regexp.MustCompile(`s\s*:\s*\[(0x[a-f0-9]+),\s*(0x[a-f0-9]+)\]`)
sMatches := sPattern.FindStringSubmatch(scriptContent)
if len(sMatches) >= 3 {
data.SValue = sMatches[1] + "," + sMatches[2]
}
return data, nil
}
Script parsing is the most fragile part of any JSD solver. Cloudflare regularly updates the script structure, which breaks extraction patterns.
Deep Dive: JSD Script Deobfuscation
The JSD script uses several obfuscation techniques. Understanding them helps you adapt when Cloudflare makes changes.
String Array Rotation: The script stores strings in an array, then accesses them by index. A rotation function shuffles the array at runtime.
// Obfuscated
var _0x4a2c = ['log', 'test', 'hello'];
(function(_0x5d23, _0x4a2c02) {
var _0x2d8f05 = function(_0x4a2c0f) {
while (--_0x4a2c0f) {
_0x5d23['push'](_0x5d23['shift']());
}
};
_0x2d8f05(++_0x4a2c02);
}(_0x4a2c, 0x1b3));
console[_0x4a2c[0]](_0x4a2c[1]); // Actually logs "hello"
Control Flow Flattening: Cloudflare converts normal code flow into switch statements. This makes static analysis harder.
// Original
function doSomething() {
step1();
step2();
step3();
}
// Flattened
function doSomething() {
var flow = '1|2|3'.split('|'), i = 0;
while (true) {
switch (flow[i++]) {
case '1': step1(); continue;
case '2': step2(); continue;
case '3': step3(); continue;
}
break;
}
}
Dead Code Injection: Random unused code gets inserted to confuse analyzers.
Proxy Functions: Simple operations get wrapped in functions with randomized names.
// Obfuscated addition
var xYz123 = function(a, b) { return a + b; };
result = xYz123(5, 3); // Just 5 + 3
To handle these obfuscations, consider using AST (Abstract Syntax Tree) transformations:
package deobfuscator
import (
"github.com/nicktedesco/goja/parser"
"github.com/nicktedesco/goja/ast"
)
func Deobfuscate(code string) (string, error) {
program, err := parser.ParseFile(nil, "", code, 0)
if err != nil {
return "", err
}
// Walk the AST and simplify patterns
ast.Walk(program, &simplifier{})
// Convert back to code
return printer.Fprint(program)
}
type simplifier struct {}
func (s *simplifier) Visit(node ast.Node) ast.Visitor {
switch n := node.(type) {
case *ast.CallExpression:
// Inline simple proxy functions
if isProxyFunction(n) {
return &inliner{original: n}
}
}
return s
}
Most JSD solvers don't fully deobfuscate the script. Instead, they extract specific values using regex patterns that remain consistent across obfuscation variants.
Step 4: Generate the Browser Fingerprint
The fingerprint payload tells Cloudflare about your "browser" environment. A realistic fingerprint is crucial for passing the challenge.
Create fingerprint.go:
package solver
import (
"encoding/base64"
"encoding/json"
"fmt"
"math/rand"
"time"
)
type Fingerprint struct {
Screen ScreenData `json:"screen"`
Navigator NavData `json:"navigator"`
Timezone int `json:"tz"`
Plugins []string `json:"plugins"`
Canvas string `json:"canvas"`
WebGL WebGLData `json:"webgl"`
Timestamp int64 `json:"ts"`
}
type ScreenData struct {
Width int `json:"w"`
Height int `json:"h"`
ColorDepth int `json:"cd"`
}
type NavData struct {
Platform string `json:"platform"`
Languages string `json:"languages"`
HardwareCon int `json:"hardwareConcurrency"`
}
type WebGLData struct {
Vendor string `json:"vendor"`
Renderer string `json:"renderer"`
}
Now add a function to generate realistic fingerprint data:
func GenerateFingerprint() *Fingerprint {
rand.Seed(time.Now().UnixNano())
screens := []ScreenData{
{Width: 1920, Height: 1080, ColorDepth: 24},
{Width: 2560, Height: 1440, ColorDepth: 24},
{Width: 1366, Height: 768, ColorDepth: 24},
{Width: 1536, Height: 864, ColorDepth: 24},
}
webgls := []WebGLData{
{Vendor: "Google Inc. (NVIDIA)", Renderer: "ANGLE (NVIDIA, NVIDIA GeForce RTX 3080 Direct3D11 vs_5_0 ps_5_0)"},
{Vendor: "Google Inc. (Intel)", Renderer: "ANGLE (Intel, Intel(R) UHD Graphics 630 Direct3D11 vs_5_0 ps_5_0)"},
{Vendor: "Google Inc. (AMD)", Renderer: "ANGLE (AMD, AMD Radeon RX 5700 XT Direct3D11 vs_5_0 ps_5_0)"},
}
return &Fingerprint{
Screen: screens[rand.Intn(len(screens))],
Navigator: NavData{
Platform: "Win32",
Languages: "en-US,en",
HardwareCon: []int{4, 8, 12, 16}[rand.Intn(4)],
},
Timezone: -300, // EST
Plugins: []string{"PDF Viewer", "Chrome PDF Viewer", "Chromium PDF Viewer"},
Canvas: generateCanvasHash(),
WebGL: webgls[rand.Intn(len(webgls))],
Timestamp: time.Now().UnixMilli(),
}
}
func generateCanvasHash() string {
chars := "abcdef0123456789"
hash := make([]byte, 32)
for i := range hash {
hash[i] = chars[rand.Intn(len(chars))]
}
return string(hash)
}
The fingerprint must stay consistent across requests during a session. Changing values mid-session triggers detection.
Real-World Fingerprint Collection
The synthetic fingerprints we generate work for basic cases. For better success rates, collect fingerprints from real browsers.
One approach uses a headless browser to extract genuine values:
package fingerprint
import (
"context"
"encoding/json"
"github.com/chromedp/chromedp"
)
func CollectRealFingerprint() (*Fingerprint, error) {
ctx, cancel := chromedp.NewContext(context.Background())
defer cancel()
var fingerprintJSON string
err := chromedp.Run(ctx,
chromedp.Navigate("about:blank"),
chromedp.Evaluate(`JSON.stringify({
screen: {
w: screen.width,
h: screen.height,
cd: screen.colorDepth
},
navigator: {
platform: navigator.platform,
languages: navigator.languages.join(','),
hardwareConcurrency: navigator.hardwareConcurrency
},
timezone: new Date().getTimezoneOffset(),
webgl: (function() {
var canvas = document.createElement('canvas');
var gl = canvas.getContext('webgl');
if (!gl) return {};
var ext = gl.getExtension('WEBGL_debug_renderer_info');
return {
vendor: gl.getParameter(ext.UNMASKED_VENDOR_WEBGL),
renderer: gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)
};
})()
})`, &fingerprintJSON),
)
if err != nil {
return nil, err
}
var fp Fingerprint
json.Unmarshal([]byte(fingerprintJSON), &fp)
return &fp, nil
}
Run this collection process periodically to build a database of valid fingerprints. Rotate through them for scraping sessions.
Fingerprint consistency rules:
- User-Agent must match the fingerprint's implied browser
- Platform must match timezone (Windows users rarely have Asia/Tokyo timezone)
- WebGL renderer should match claimed GPU
- Screen resolution should be common for the claimed platform
Step 5: Compress and Encode the Payload
Cloudflare expects the fingerprint payload in a specific format. The data gets JSON-stringified, compressed with LZ-string encoding, then URL-encoded.
Create compress.go:
package solver
import (
"bytes"
"compress/gzip"
"encoding/base64"
"net/url"
"strings"
)
func CompressPayload(data interface{}) (string, error) {
jsonBytes, err := json.Marshal(data)
if err != nil {
return "", err
}
// LZ-string compress
compressed := lzStringCompress(string(jsonBytes))
// Base64 encode
encoded := base64.StdEncoding.EncodeToString([]byte(compressed))
// URL encode
return url.QueryEscape(encoded), nil
}
func lzStringCompress(input string) string {
// Simplified LZ compression
// Full implementation requires the complete LZ-string algorithm
var result strings.Builder
dict := make(map[string]int)
dictSize := 256
for i := 0; i < 256; i++ {
dict[string(rune(i))] = i
}
w := ""
for _, c := range input {
wc := w + string(c)
if _, exists := dict[wc]; exists {
w = wc
} else {
result.WriteRune(rune(dict[w]))
dict[wc] = dictSize
dictSize++
w = string(c)
}
}
if w != "" {
result.WriteRune(rune(dict[w]))
}
return result.String()
}
The actual LZ-string compression used by Cloudflare is more complex. The jsd-solver-go library includes a complete implementation in its utils package.
Step 6: Submit the Oneshot Payload
With all components ready, you can now submit the challenge payload. Create the main solver file:
package solver
import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
type Solver struct {
client *Client
targetURL string
debug bool
}
type SolveResult struct {
Success bool
StatusCode int
CfClearance string
Cookies []*http.Cookie
Body string
}
func NewSolver(targetURL string, debug bool) (*Solver, error) {
parsed, err := url.Parse(targetURL)
if err != nil {
return nil, err
}
return &Solver{
client: NewClient(),
targetURL: parsed.Scheme + "://" + parsed.Host,
debug: debug,
}, nil
}
Add the main solve method:
func (s *Solver) Solve() (*SolveResult, error) {
// Step 1: Fetch the target page
resp, err := s.client.Get(s.targetURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to fetch target: %w", err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
// Extract CF-Ray from header as fallback
cfRay := ExtractRayFromHeader(resp.Header.Get("CF-Ray"))
// Step 2: Extract challenge parameters
params, err := ExtractParams(string(body))
if err != nil {
// Try using CF-Ray header
if cfRay != "" {
params = &ChallengeParams{R: cfRay}
} else {
return nil, fmt.Errorf("failed to extract params: %w", err)
}
}
// Use CF-Ray if R not found in HTML
if params.R == "" {
params.R = cfRay
}
// Step 3: Fetch and parse JSD script
scriptContent, err := s.client.FetchScript(s.targetURL, params.ScriptURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch script: %w", err)
}
scriptData, err := ParseScript(scriptContent)
if err != nil {
return nil, fmt.Errorf("failed to parse script: %w", err)
}
// Step 4: Generate fingerprint
fingerprint := GenerateFingerprint()
// Step 5: Compress payload
payload, err := CompressPayload(fingerprint)
if err != nil {
return nil, fmt.Errorf("failed to compress payload: %w", err)
}
// Step 6: Submit oneshot
return s.submitOneshot(params, scriptData, payload, resp.Cookies())
}
The submitOneshot method sends the final POST request:
func (s *Solver) submitOneshot(params *ChallengeParams, scriptData *ScriptData, payload string, cookies []*http.Cookie) (*SolveResult, error) {
endpoint := s.targetURL + scriptData.Endpoint
formData := fmt.Sprintf("v_%s=%s", params.R, payload)
req, err := fhttp.NewRequest("POST", endpoint, strings.NewReader(formData))
if err != nil {
return nil, err
}
s.client.setHeaders(req)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", s.targetURL)
req.Header.Set("Referer", s.targetURL + "/")
for _, cookie := range cookies {
req.AddCookie(cookie)
}
resp, err := s.client.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
result := &SolveResult{
StatusCode: resp.StatusCode,
Cookies: resp.Cookies(),
Body: string(body),
}
// Check for cf_clearance cookie
for _, cookie := range resp.Cookies() {
if cookie.Name == "cf_clearance" {
result.CfClearance = cookie.Value
result.Success = true
break
}
}
return result, nil
}
Using the JSD Solver
Here's a complete example showing how to use your solver:
package main
import (
"fmt"
"log"
"github.com/yourusername/jsd-solver/solver"
)
func main() {
s, err := solver.NewSolver("https://www.example.com", true)
if err != nil {
log.Fatal(err)
}
result, err := s.Solve()
if err != nil {
log.Fatal(err)
}
if result.Success {
fmt.Printf("Success! cf_clearance: %s\n", result.CfClearance)
} else {
fmt.Printf("Failed with status: %d\n", result.StatusCode)
}
}
For production use, you'll want to add retry logic and proxy support. The cf_clearance cookie is tied to your IP address, so using quality residential proxies from providers like Roundproxies.com helps maintain consistent sessions.
Testing Your JSD Solver
Before deploying your solver, test it against known JSD-protected sites. Create a test suite that validates each component:
package solver_test
import (
"testing"
"github.com/yourusername/jsd-solver/solver"
)
func TestExtractParams(t *testing.T) {
html := `<script>(function(){window['__CF$cv$params']={r:'abc123',t:'dGVzdA=='}})()</script>`
params, err := solver.ExtractParams(html)
if err != nil {
t.Fatalf("extraction failed: %v", err)
}
if params.R != "abc123" {
t.Errorf("expected r='abc123', got '%s'", params.R)
}
}
func TestFingerprintGeneration(t *testing.T) {
fp := solver.GenerateFingerprint()
if fp.Screen.Width == 0 {
t.Error("screen width should not be zero")
}
if fp.Navigator.Platform == "" {
t.Error("platform should not be empty")
}
}
func TestPayloadCompression(t *testing.T) {
data := map[string]string{"test": "value"}
compressed, err := solver.CompressPayload(data)
if err != nil {
t.Fatalf("compression failed: %v", err)
}
if compressed == "" {
t.Error("compressed payload should not be empty")
}
}
Create integration tests that actually solve challenges:
func TestSolveIntegration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
// Use a known JSD-protected test site
s, err := solver.NewSolver("https://example-jsd-site.com", false)
if err != nil {
t.Fatal(err)
}
result, err := s.Solve()
if err != nil {
t.Fatalf("solve failed: %v", err)
}
if !result.Success {
t.Errorf("expected success, got status %d", result.StatusCode)
}
if result.CfClearance == "" {
t.Error("expected cf_clearance cookie")
}
}
Run tests regularly. A failing test indicates Cloudflare changed something.
Common Issues and Troubleshooting
Getting 403 Forbidden responses: Your TLS fingerprint doesn't match Chrome. Verify you're using fhttp correctly and sending headers in the proper order.
Empty cf_clearance cookie: The fingerprint data failed validation. Check that your canvas hash and WebGL values look realistic.
Script parsing fails: Cloudflare updated the JSD script. You'll need to analyze the new script structure and update your regex patterns.
Challenge loops infinitely: The payload format changed. Inspect network requests in a real browser to see the expected format.
Keeping Your JSD Solver Updated
Cloudflare continuously updates their protection. A JSD solver that works today might break tomorrow. To maintain your solver:
Monitor the JSD script for changes by comparing versions. Store hashes of known working scripts and alert when they change.
Join communities that track Cloudflare updates. Other developers often discover changes before they affect production systems.
Test your solver daily against multiple target sites. Different sites may receive different script versions.
Keep backup fingerprint profiles. If one profile gets flagged, rotate to another.
Advanced: Manual Mode Without Homepage Fetch
Sometimes you already have the r and t parameters from another source. Maybe your system already fetched the page, or you're processing challenge data asynchronously.
The jsd-solver-go library supports a manual mode:
func (s *Solver) SolveFromData(data SolveData) (*SolveResult, error) {
// Validate required parameters
if data.R == "" {
return nil, errors.New("R parameter is required")
}
params := &ChallengeParams{
R: data.R,
T: data.T,
ScriptURL: data.ScriptURL,
}
// Use default script URL if not provided
if params.ScriptURL == "" {
params.ScriptURL = "/cdn-cgi/challenge-platform/scripts/jsd/main.js"
}
// Continue with script fetch and solving
scriptContent, err := s.client.FetchScript(s.targetURL, params.ScriptURL)
if err != nil {
return nil, err
}
// ... rest of solving logic
}
This approach lets you integrate the solver into existing pipelines without redundant HTTP requests.
Performance Optimization Tips
JSD solving adds latency to your scraping workflow. Each solve requires multiple HTTP roundtrips. Here are strategies to minimize impact.
Connection pooling: Reuse HTTP connections across requests. The fhttp client supports keep-alive by default, but ensure you're not closing connections prematurely.
transport := &fhttp.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
}
Cookie caching: Store valid cf_clearance cookies and reuse them until expiration. Don't solve challenges unnecessarily.
type CookieCache struct {
mu sync.RWMutex
cookies map[string]*CachedCookie
}
type CachedCookie struct {
Value string
ExpiresAt time.Time
}
func (c *CookieCache) Get(domain string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if cached, ok := c.cookies[domain]; ok {
if time.Now().Before(cached.ExpiresAt) {
return cached.Value, true
}
}
return "", false
}
Parallel solving: If you're solving for multiple domains, run solvers concurrently. Each domain requires its own solution anyway.
func SolveMultiple(urls []string) []*SolveResult {
results := make([]*SolveResult, len(urls))
var wg sync.WaitGroup
for i, url := range urls {
wg.Add(1)
go func(idx int, targetURL string) {
defer wg.Done()
solver, _ := NewSolver(targetURL, false)
result, _ := solver.Solve()
results[idx] = result
}(i, url)
}
wg.Wait()
return results
}
Fingerprint generation: Pre-generate fingerprint pools instead of creating new ones for each request. Random generation has overhead.
Proxy Integration for Production Use
In production, you'll want proxy support. Different proxies have different IP reputations with Cloudflare. Datacenter IPs often get blocked immediately, while residential IPs pass more frequently.
Add proxy support to your client:
func NewClientWithProxy(proxyURL string) (*Client, error) {
proxyParsed, err := url.Parse(proxyURL)
if err != nil {
return nil, err
}
transport := &fhttp.Transport{
Proxy: fhttp.ProxyURL(proxyParsed),
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
}
return &Client{
httpClient: &fhttp.Client{
Transport: transport,
Timeout: 30 * time.Second,
},
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
}, nil
}
Remember that the cf_clearance cookie is bound to the IP that solved the challenge. Your subsequent requests must use the same proxy IP.
Error Handling and Retry Logic
Production JSD solvers need robust error handling. Network issues, Cloudflare updates, and rate limiting all cause failures.
Implement exponential backoff:
func (s *Solver) SolveWithRetry(maxRetries int) (*SolveResult, error) {
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
result, err := s.Solve()
if err == nil && result.Success {
return result, nil
}
lastErr = err
if err == nil {
lastErr = fmt.Errorf("solve failed with status %d", result.StatusCode)
}
// Exponential backoff
sleepDuration := time.Duration(math.Pow(2, float64(attempt))) * time.Second
time.Sleep(sleepDuration)
}
return nil, fmt.Errorf("max retries exceeded: %w", lastErr)
}
Handle specific error cases:
func categorizeError(statusCode int, body string) string {
switch {
case statusCode == 403:
if strings.Contains(body, "ray") {
return "fingerprint_rejected"
}
return "access_denied"
case statusCode == 429:
return "rate_limited"
case statusCode >= 500:
return "server_error"
default:
return "unknown"
}
}
Debugging and Logging
When things go wrong, detailed logs help diagnose issues. Add structured logging to your solver:
type Logger interface {
Debug(msg string, fields map[string]interface{})
Error(msg string, fields map[string]interface{})
}
func (s *Solver) Solve() (*SolveResult, error) {
s.log.Debug("starting solve", map[string]interface{}{
"target": s.targetURL,
})
// After each step, log relevant data
s.log.Debug("extracted params", map[string]interface{}{
"r": params.R,
"t": params.T,
"script_url": params.ScriptURL,
})
// Log fingerprint details (in debug mode only)
if s.debug {
s.log.Debug("generated fingerprint", map[string]interface{}{
"screen": fingerprint.Screen,
"webgl_vendor": fingerprint.WebGL.Vendor,
})
}
// Log final result
s.log.Debug("solve complete", map[string]interface{}{
"success": result.Success,
"status": result.StatusCode,
})
}
Save request/response pairs during development. When Cloudflare changes something, you can compare working and broken requests to identify differences.
Comparing Approaches: Browser vs HTTP Solver
Before committing to an HTTP-based JSD solver, understand the tradeoffs compared to browser-based solutions.
HTTP Solver Advantages:
- Extremely fast (milliseconds vs seconds)
- Low resource usage (no browser processes)
- Easy to scale horizontally
- Predictable memory consumption
HTTP Solver Disadvantages:
- Breaks when Cloudflare updates scripts
- Requires ongoing maintenance
- Limited to JSD challenges only
- Fingerprint quality may trigger detection
Browser-Based Advantages:
- Handles all challenge types automatically
- Real browser fingerprints
- Less maintenance when Cloudflare updates
- Works with JavaScript-heavy sites
Browser-Based Disadvantages:
- Slow (2-10 seconds per solve)
- High resource usage
- Complex to scale
- Browser detection is possible
For high-volume scraping with mostly JSD-protected sites, the HTTP approach wins. For occasional scraping of varied protections, browsers make sense.
Conclusion
Building a JSD solver requires understanding Cloudflare's challenge flow, proper TLS fingerprinting, and careful payload construction. The solver extracts parameters from HTML, parses the JSD script, generates realistic fingerprints, and submits the oneshot payload.
While this guide provides a working foundation, expect to maintain and update your solver regularly. Cloudflare's cat-and-mouse game with scrapers means no solution stays working forever.
For projects where maintaining a custom solver isn't practical, consider using the jsd-solver-go library which handles much of this complexity automatically.
FAQ
What is the difference between JSD and Turnstile?
JSD (JavaScript Detection) runs invisibly without user interaction. It checks if your browser executes JavaScript and collects fingerprint data. Turnstile is a visible CAPTCHA widget that requires user interaction or automated clicking. JSD sets a cf_clearance cookie after successful fingerprint validation, while Turnstile returns a token that your backend must verify.
Can I use a JSD solver with headless browsers?
You can, but it defeats the purpose. JSD solvers exist specifically to avoid browser overhead. If you're running Puppeteer or Playwright anyway, let the browser handle the challenge naturally. JSD solvers make sense when you want fast, lightweight requests without browser instances.
How long does the cf_clearance cookie last?
The cf_clearance cookie typically lasts 30 minutes to several hours, depending on the site's configuration. After expiration, you'll need to solve the challenge again. Store the cookie and reuse it for subsequent requests within its validity period.
Why does my solver work on some sites but not others?
Cloudflare serves different JSD script variants to different sites. The deobfuscation patterns that work for one site might not match another site's script structure. Additionally, some sites have stricter bot management settings that require more sophisticated fingerprinting.
Is building a JSD solver legal?
Building the solver itself is legal. How you use it determines legality. Accessing public data for research or personal use is generally acceptable. Scraping personal data, bypassing paywalls, or violating a site's terms of service can create legal issues. Always check local laws and the target site's robots.txt before scraping.