Inside the DataDome JS tag: what ddjskey and the client payload carry
Open the network panel on a site behind DataDome and the first thing you notice is a script tag pointing at js.datadome.co/tags.js, loaded async, preceded by two lines that set window.ddjskey and window.ddoptions. That is the entire public surface of the client side. One key, one options object, one script. Everything else, the fingerprint collection, the behavioral capture, the request interception, the challenge handoff, happens inside a heavily obfuscated bundle that DataDome rotates on a schedule and now compiles to its own bytecode.
This post is about that bundle and the data it sends home. Not the server-side scoring, which decides whether you are a bot after the payload lands. The client tag: what ddjskey identifies, what the collector reads out of the browser, the shape of the POST it makes to api-js.datadome.co/js/, and what happens when the verdict comes back as a challenge instead of a cookie. Where the internal layout is genuinely not public, I will say so and mark the difference between what DataDome documents, what independent researchers have observed on the wire, and what is inference.
A short map of what follows. First the two-variable bootstrap and what ddjskey actually is. Then the jsData payload and the named fields researchers have pulled out of it. Then the events array and the behavioral side. Then request interception, the datadome cookie, and the handoff to captcha-delivery.com when the score says challenge. Last, the obfuscation story, because it changes how much of any of this you can still see in 2026.
The two-variable bootstrap
The integration DataDome documents is almost aggressively small. You set a key, you set an options object, you load a script:
<script> window.ddjskey = 'YOUR_DATADOME_JS_KEY'; window.ddoptions = {};</script><script src="https://js.datadome.co/tags.js" async></script>ddjskey is the client-side key. DataDome’s own docs call it out as safe to expose publicly, which is the correct mental model: it is a site identifier, not a secret. It travels back to the collector as the ddk field in the payload, and the backend uses it to associate the incoming signals with your account and the right detection configuration. Because it is public by design, the key alone gets an attacker nothing. The protection is in what the tag computes around it, not in keeping the key hidden.
ddoptions is where integrators tune behavior. The documented properties are a finite list, and reading them tells you a lot about what the tag is wired to do. As of the current docs the object accepts abortAsyncOnChallengeDisplay, ajaxListenerPath, ajaxListenerPathExclusion, challengeLanguage, challengeRoot, disableAutoRefreshOnCaptchaPassed, enableServiceWorkerPlugin, enableTagEvents, endpoint, exposeCaptchaFunction, overrideAbortFetch, replayAfterChallenge, sessionByHeader, and withCredentials. Three of those matter for understanding the architecture. endpoint overrides where signals are posted, defaulting to https://api-js.datadome.co/js/ or the /js/ route on the tag’s own origin when self-hosted. ajaxListenerPath controls which outgoing requests the tag wraps. enableServiceWorkerPlugin opts into a second collection thread, which I will come back to.
One detail in the docs is worth pulling out because people trip on it. The tag overrides XMLHttpRequest and Fetch, and it needs read and write access to the datadome cookie. DataDome warns integrators not to tamper with cookie attributes, and specifically not to set HttpOnly on it, because the JavaScript has to read and rewrite it. A cookie the script cannot touch breaks the whole client flow. That single requirement tells you the datadome cookie is not a server-only session token. It is shared state between the tag and the edge, and the tag is an active participant in its lifecycle. There is a separate piece on the DataDome cookie lifecycle if you want the issuance-and-rotation detail.
The jsData payload
When the tag has read the browser it builds a payload and POSTs it to api-js.datadome.co/js/. The body is form-encoded and carries a handful of top-level fields plus one large blob. The top-level fields that researchers consistently observe are ddk (your ddjskey), cid (the client identifier, the same value carried in the datadome cookie), request (the path being protected), ddv (a tag version identifier), and responsePage. The blob is jsData, a JSON object stringified into the form body, and it is where the fingerprint lives.
A caveat before the field list. DataDome obfuscates the tag specifically so that the exact jsData schema is not stable or public, and they rotate field names and structure on a schedule. What follows is the set of field names independent researchers have pulled out of observed traffic and documented in solver and reverse-engineering write-ups. Treat them as representative of the categories the tag collects, not as a guaranteed-current wire format. The categories have been stable for years even as the exact keys churn.
DataDome’s own materials say jsData carries “over 30 data points,” varying by device, peripherals, display, browser, and geolocation. Grouped by what they read:
Screen and window geometry shows up as a cluster of short keys. Observed names include br_oh and br_ow for outer height and width, br_h and br_w for inner dimensions, alongside screen resolution and color depth. Browser identity comes through ua (user agent string), lg (language), and timezone fields tz and tzp. None of that is exotic; every fingerprinting library reads the same surface. The point of collecting it is cross-checking, not novelty. A ua claiming Windows Chrome whose timezone, language, and screen metrics do not line up with the population of real Windows Chrome users is a cheap inconsistency to flag.
The plugin and codec cluster is more telling. Researchers report plg, plgne, and plgre covering plugin presence and counts, plus a block of media-capability flags for audio and video codec support, with observed keys like ac3, acaa, and vcmp. Codec support is a good device-class signal because it is hard to fake coherently. A headless Chrome build often ships without the proprietary codecs that a real Chrome install carries, and the gap between claimed user agent and actual canPlayType results is a classic automation tell.
Then the GPU pair. glvd and glrd carry the WebGL unmasked vendor and renderer strings, the values you get from the WEBGL_debug_renderer_info extension. On a real machine those read like a specific GPU and driver. On a virtualized or software-rendered environment they read like SwiftShader, llvmpipe, or a generic ANGLE string, and that is exactly the population DataDome wants to separate out. The GPU strings also feed the device-class fingerprinting DataDome runs, which it builds on the Picasso protocol. More on that below.
Finally, a set of timing fields. Observed keys include ttst, tagpu, and jset, which look like execution timings for the tag’s own work: how long collection took, how long a GPU probe took, when the tag started. Timing is a side-channel against automation. A function that runs suspiciously fast or suspiciously uniformly, or whose sub-timings do not match the spread you see on real hardware, is information. The tag is partly measuring itself.
The thing to take from this is not any individual field. It is that the payload is built for consistency checking. DataDome has written publicly that the value is in correlating signals against each other and against population baselines, and a single payload gives the server dozens of independently-spoofable claims that all have to agree. The full first-request signal set, including the parts that arrive before the tag even runs, is the subject of a separate piece on DataDome’s detection model.
What it deliberately does not collect
DataDome has gone out of its way to say the tag avoids canvas fingerprinting in the tracking sense, on privacy grounds, framing the collection as bot-detection rather than user-tracking. That distinction is real but narrower than it sounds. The tag does use canvas and WebGL rendering, but as a device-class probe rather than a per-user identifier. The mechanism is Picasso, a protocol Google’s Elie Bursztein and colleagues published in 2016 for lightweight device-class fingerprinting. The server sends a seeded sequence of drawing instructions, curves, arcs, fonts, the client renders them off-screen and hashes the pixels, and the hash depends on the actual GPU, driver, OS, and browser stack underneath. The result classifies a device into a browser-and-OS bucket rather than singling out a person, and because each challenge carries a fresh seed it cannot be replayed from a saved value. A bot claiming to be Chrome on Windows but rendering like headless Chrome on Linux produces a Picasso hash that does not match its stated class. That is the inconsistency DataDome is hunting, and it is why simply spoofing the WebGL vendor strings in jsData is not enough on its own.
The events array and the behavioral side
Alongside jsData, the payload carries events, an array of interaction events the tag captures while the page is open: mouse movements, scrolls, key presses, touch events on mobile. There is also an eventCounters field, which researchers describe as usable both as a signal on its own and as an integrity check on the events array, a way to detect when the reported events do not add up to the counts the tag recorded internally.
Behavioral collection is the half of the tag that needs time. Fingerprint fields can be read synchronously the moment the script runs. Behavior accrues, so the tag listens and batches, and the events it sends describe the texture of how a pointer moved and how keys were struck. Human cursor paths have characteristic acceleration curves, micro-jitter, and the sort of overshoot-and-correct you get from a hand on a mouse. Programmatic input tends to be too straight, too evenly timed, or absent entirely. None of this is unique to DataDome; every serious client-side detector reads the same telemetry, and the Akamai sensor_data payload collects a closely comparable set of pointer and key events.
The eventCounters-as-integrity-check detail is the more interesting one, because it points at a recurring theme in how these systems defend the payload itself. It is not enough to ask “did the client send plausible mouse movements.” The server also asks “is this events array internally consistent with the metadata the tag attached to it.” A solver that hand-assembles a payload with a believable-looking events array but the wrong counters has tripped a check that has nothing to do with whether the movements look human. The payload is built to make partial forgery detectable, and that design recurs across the client side.
There is a second, quieter reason the behavioral side matters. It forces time and interaction. A fingerprint blob can be assembled and posted in the first few milliseconds after the script parses, with no page interaction at all. Behavioral data cannot. To produce an events array that looks like a person used the page, something has to actually use the page, or convincingly simulate having done so over real wall-clock time. That raises the floor on the cheapest kind of automation, the request-replay client that never renders anything. Such a client can copy a jsData blob, but it has no pointer trail to copy, and a payload that arrives with rich device fields and an empty or synthetic interaction history is its own kind of inconsistency. The behavioral capture is not only measuring how a human moves. It is also a liveness tax on the request path.
The service worker collection thread
One of the documented ddoptions flags is enableServiceWorkerPlugin, and it points at a part of the tag that is easy to miss because it does not run on the page’s main thread. DataDome ships a service-worker plugin, distributed as the npm package @datadome/service-worker-plugin, that moves some of the heavier collection work off the main thread and into a worker. The stated reason is performance. Computing certain signals is expensive enough that doing it inline would block rendering and hurt the page it is meant to protect, so DataDome put those signals on a separate thread to avoid stalling the main one.
The performance framing is honest, but the architectural consequence is worth noting on its own. A service worker sits between the page and the network, which means collection running there has a different vantage point from a script running in the document. It also means part of the tag’s work happens in an execution context that an analyst poking at the page’s main thread does not immediately see. That is not the primary motivation, but it is a side effect that cuts in DataDome’s favor. The more the collection is spread across threads and contexts, the more an observer has to look in several places at once to reconstruct what the tag does, and the easier it is for a partial reimplementation to miss a signal that was being computed somewhere it never thought to look.
Worth being precise about scope here: the worker is an optional, opt-in piece that an integrator enables, not something every DataDome-protected site runs. When it is off, the same categories of signal still get collected on the main thread. The flag changes where the work happens, not whether it happens. But on sites that turn it on, a faithful picture of what the tag gathers has to account for the worker, and a network capture that only watches the document’s requests can miss it.
Interception, the cookie, and the handoff to a challenge
The tag’s other job is to sit in the request path. It overrides XMLHttpRequest and Fetch so it can watch the responses to protected requests and act when one comes back as a block. Which requests it wraps is governed by ajaxListenerPath; by default it matches the current host as a substring of the outgoing URL, including subdomains, and integrators narrow or widen that with path patterns. When a wrapped request returns a DataDome block, the tag is positioned to catch it, suspend the original request, and present the challenge in place, then optionally replay the original request after the challenge passes, which is what replayAfterChallenge controls.
The decision itself does not happen on the client. The /js/ POST returns a verdict, and the protected request gets evaluated at the edge against the score. If the verdict is “allow,” the response carries an updated datadome cookie and the request proceeds. If the verdict is “challenge,” the protected request comes back with a 403 and a challenge response instead of the content. That edge-to-decision path, the part between the payload landing and the cookie or the 403 coming back, is its own subject, covered in the DataDome scoring pipeline. The client tag’s role is to feed the input and handle the output.
A couple of the other ddoptions flags shape how the continuity is carried. By default the proof of clearance rides in the datadome cookie, which is why the tag needs read and write access to it. sessionByHeader switches that to a header-based scheme instead, for integrations where a cookie is awkward, single-page apps talking to an API on another origin, or native contexts that do not have a cookie jar in the usual sense. withCredentials controls whether the tag’s own intercepted requests carry credentials, mirroring the standard Fetch and XHR semantics so the tag’s wrapping does not silently change whether cookies go out on a cross-origin call. These are small switches, but they explain why two DataDome-protected sites can look different on the wire while running the same tag: one threads the token through a cookie, another through a header, and the cid continuity works the same way underneath either.
The interception also has to be careful not to break the page it sits in. That is what replayAfterChallenge, abortAsyncOnChallengeDisplay, and overrideAbortFetch are for. When a request gets blocked and a challenge has to be shown, the tag has to decide what to do with the original request and any others in flight: hold them, abort them, or let them error. After a successful challenge it can replay the held request so the user does not notice anything beyond a brief pause. The flags exist because the correct behavior depends on the application. An infinite-scroll feed and a checkout form want different answers to “what happens to the request that was interrupted,” and the tag exposes the choice rather than guessing.
When the verdict is challenge, the response body carries a dd object, a small JSON structure with the parameters the challenge page needs. The fields observed across solver documentation include rt (a response-type flag that distinguishes the challenge variant), cid, hsh (a hash), host (the delivery hostname), and short parameters s and b. The rt value separates the two challenge styles. An interstitial device check, where the page may show a brief “verifying your browser” message or a blank pane while a script runs, loads from a script at ct.captcha-delivery.com/i.js. The slider style, an image puzzle the user drags to solve, loads from ct.captcha-delivery.com/c.js and pulls a few extra parameters into its dd object, including t and e.
The challenge then runs against geo.captcha-delivery.com. For the interstitial path, the page assembles a device-check URL of the shape https://geo.captcha-delivery.com/interstitial/?initialCid={cid}&hash={hsh}&cid={datadomeCookie}&referer={referer}&s={s}&b={b}&dm=cd, runs its own collection inside that frame, and posts back. The slider path uses the parallel /captcha/ route with its own parameter set. Either way, a successful solve returns a fresh datadome cookie value, and that cookie is the proof of clearance the next request carries. Notice that cid shows up in two roles: once as the challenge ID from the dd object, and once as the existing datadome cookie value threaded into the URL. The cookie is the continuity thread through the whole sequence.
This is the part of the system most people interact with directly, because it is the visible one. The interstitial is the blank or “verifying” pane. The slider is the puzzle. But both are downstream of a verdict the tag’s payload already triggered. The challenge is not the test. The payload was the test; the challenge is what happens when the payload did not clear the bar.
The obfuscation story, and why 2026 is different
Everything above is observable in principle, which is the whole reason DataDome spends so much effort making it hard to observe in practice. The tag has always been protected. DataDome describes a custom in-house obfuscator with dynamic generation, time-bounded execution, and anti-debugging traps, and they rotate the obfuscation on a schedule so that variable names, structure, and encryption keys change regularly. That is why the jsData keys you read off the wire today may not match the ones in a write-up from 2023, even though both describe the same underlying collection. The categories are stable. The surface churns deliberately.
The current state is a step beyond name-shuffling. In February 2026, DataDome shipped VM-based obfuscation for its Device Check and Slider challenges, layered on top of the existing dynamic and WebAssembly protections. The idea is to change how the code runs rather than only what it looks like. A proprietary compiler translates the detection logic into custom bytecode at build time, and a virtual machine built in the browser reads that bytecode and executes it opcode by opcode. The original JavaScript source for the detection logic never ships. What ships is a sequence of numbers, an opcode followed by operands, interpreted by a VM whose instruction set DataDome remaps on its rotation schedule. Static analysis loses its footing because there is no source to read, and dynamic analysis loses its footing because the execution boundaries blur inside the interpreter.
The practical consequence for anyone trying to understand the tag from the outside is that the window for reading field names off a deobfuscated bundle is closing. The categories of signal are well understood and stable, and the network-level shape, the /js/ POST, the datadome cookie, the dd object, the captcha-delivery.com hosts, is still visible because it has to cross the wire. But the precise computation that turns a browser into a jsData blob is moving into an interpreter that is designed to be illegible. That is a deliberate asymmetry. DataDome can change the VM and the bytecode on every release; an analyst has to re-derive the semantics each time. The cost of looking is now a recurring cost, not a one-time one.
What the client tag is, in the end
Strip away the obfuscation and the client tag is doing three plain things. It reads a few dozen browser surfaces and bundles them into jsData. It watches the pointer and keyboard and bundles that into events. And it sits in the request path so it can route the verdict, a cookie or a challenge, back into the page. The ddjskey is just the label on the envelope, public by design, identifying which account and configuration the signals belong to. None of the individual pieces is secret or novel. Screen metrics, codec flags, WebGL strings, mouse paths: every fingerprinting library reads them.
The design value is in the cross-checking and in making the payload expensive to forge convincingly. A spoofed user agent has to agree with a spoofed timezone, a spoofed plugin list, a spoofed set of codec capabilities, a Picasso render that classifies to the same device bucket, an events array whose counters add up, and a set of self-timings that match real hardware. Any one of those is easy. All of them at once, consistently, on a tag that recompiles its collection logic into fresh bytecode on a schedule, is the actual work. The point of the VM obfuscation shipped in 2026 is not to hide the field names. It is to make the field names a moving target, so that the knowledge an analyst extracts has a short shelf life and the asymmetry of cost stays tilted toward the defender.
Sources & further reading
- DataDome (2026), JavaScript Tag Integration for Bot Protect — official integration docs naming
ddjskey,ddoptions, thetags.jssource, and the XHR/Fetch override and cookie requirements. - DataDome (2026), How to configure the JavaScript Tag — the full enumerated list of
ddoptionsproperties and the defaultapi-js.datadome.co/js/endpoint. - DataDome (2017), JavaScript tag 2.0.1 changelog — dated 7 September 2017, the release that introduced the options system the tag still uses.
- DataDome (2026), VM-Based Obfuscation: The Next Evolution in Client-Side Detection Security — February 2026 changelog describing the custom bytecode VM layered over dynamic and WASM obfuscation.
- DataDome (2024), The Art of Bot Detection: How DataDome Uses Picasso for Device Class Fingerprinting — how DataDome applies the Picasso canvas/WebGL protocol for device-class fingerprinting.
- Bursztein, Malyshev, Pietraszek, Thomas (2016), Picasso: Lightweight Device Class Fingerprinting for Web Clients — the original Google paper on seeded canvas rendering for device-class verification (SPSM 2016).
- Hyper Solutions (2026), DataDome interstitial documentation — the
ddobject fields, thegeo.captcha-delivery.com/interstitial/URL structure, and the 403 challenge flow. - Hyper Solutions (2026), DataDome getting started — the interstitial vs slider split, the
ct.captcha-delivery.com/i.jsand/c.jsscripts, and thedatadomecookie role. - TakionAPI (2026), DataDome documentation — the
rtresponse-type flag distinguishing interstitial from the GeeTest-style slider, with exampleddobject fields. - glizzykingdreko (2023), Breaking Down DataDome Captcha WAF — researcher write-up on the challenge response structure and the
api-js.datadome.co/js/payload. - ellisfan (2024), bypass-datadome — repository documenting observed
jsDatafield names (glvd,glrd,plg,br_oh,ttst, and others) and the top-levelddk/cid/ddvfields.
Further reading
Inside the Cloudflare challenge platform: anatomy of the cf-chl orchestration
A primary-source walk through the Cloudflare interstitial: the window._cf_chl_opt object, the /cdn-cgi/challenge-platform/h/ orchestration endpoints, the obfuscated client script, and how a cf_clearance pass is returned.
·18 min readKasada's KPSDK: the 128-bit token, the VM, and the obfuscated bytecode
Traces how Kasada's client SDK works: the x-kpsdk-ct and x-kpsdk-cd tokens, the obfuscated JavaScript VM that runs Kasada-specific bytecode, the proof-of-work it computes, and how the payload rotates per tenant.
·19 min readDeobfuscating anti-bot JavaScript: the workflow for VM-based protections
How to read obfuscated anti-bot JavaScript without running it blind: beautify, scope and string-array recovery, Babel AST transforms, runtime hooking, and where the workflow hits a wall against bytecode VMs.
·20 min read