← blog
Reverse Engineering

x-client-transaction-id: how I re-reversed X's antibot

The reference code for this article lives in the repo: the lab, the string decoder, the Go signing server, and the validation harness.

If you scrape X past the login wall you know the x-client-transaction-id header. Three GraphQL operations (SearchTimeline, UserTweetsAndReplies, Followers) return a naked 404 without it: no body, no error, nothing. The header is computed per request, bound to materials the page ships, single-use. Replay a captured value and you get 404. Guess a value and you get 404. There is no error message to learn from, which is the point. Full lab and Go port: github.com/ichioda/x-client-transaction-id.

The community solved this once, in 2025. The antibot.blog series and the MIT-licensed XClientTransaction documented a full pipeline: a key from a meta tag, indices from an on-demand chunk, a simulated SVG animation, SHA-256(method!path!time + keyword), an XOR mask, base64. Ports exist in Python, Rust, JS, Deno.

Then, at some point in 2026, they all started dying with Couldn't get KEY_BYTE indices.

I had a port "working" again within a day, and a problem with it. My implementation inherited the algorithm from those writeups, validated it against the live client, and never once read the code that computes the header. A photocopy of a photocopy, the contrast fading with every pass, and I was calling the blur my own work. So I threw the first version away, built a proper lab, and did the work again with the bundles open on the table. This post is the full investigation: the dead ends, the debugging, the deobfuscation, and four things about the generator I could not find in any public writeup.

Where this starts

The 2025 pipeline, as documented:

  1. fetch x.com/home, parse the twitter-site-verification meta tag (a base64 key, 48 bytes);
  2. find the ondemand.s chunk reference in the HTML (a webpack chunk map, ,(\d+):"ondemand\.s" style), resolve its hash, download the chunk;
  3. regex the chunk for key-byte indices, \(\w{1}\[(\d{1,2})\],\s*16\);
  4. harvest the four loading-x-anim-* SVG frames from the DOM;
  5. derive an "animation key" by simulating a cubic Bézier easing at a time derived from the key bytes;
  6. assemble SHA-256("{METHOD}!{path}!{timeNow}" + keyword + animationKey), truncate to 16 bytes, prepend the key bytes and a little-endian timestamp, append a constant 0x03, XOR everything with one random byte, base64.

My plan was boring on purpose: re-verify each step against the live site, port to Go, prove the port byte-identical against captured traffic. The first step of that plan died in the first browser session.

The map stopped matching the territory

I instrumented a real session and pulled the raw HTML of the landing page. First surprise: it is 32 KB, and it contains the key and the four SVG frames just fine. Second surprise:

sh
$ grep -aoc 'ondemand' home.html
0

Zero. Not a regex mismatch; the string is not in the document at all, in any form. The documented discovery step, the one every library runs, has nothing to bite on. I checked the old JSON-style format too ("ondemand.s":"hex"), the chunk-map format, anything:

py
$ python - <<'EOF'
import re
html = open('home.html', encoding='utf-8', errors='replace').read()
print(re.search(r',(\d+):["\']ondemand\.s["\']', html))   # lib's current regex → None
print(re.search(r'"ondemand\.s":"([0-9a-f]+)"', html))    # old format        → None
print(len(re.findall(r'\{(\d+:"[0-9a-f]{8,}",?){3,}', html)))  # inline maps → 0
EOF
None
None
0

The chunk was being loaded, though. performance.getEntriesByType("resource") showed ondemand.s.b24a9fa951034649a.js right there, alongside sixty-some siblings. So the URL is computed at runtime. By what?

The answer was sitting in the script tag:

html
<script type="module" src="https://abs.twimg.com/x-web/x-web/entry-client-logged-out-aXkEDy-Q.js">

The landing page migrated to Rolldown, the Rust bundler from the Vite team. The imports tell the story: ./assets/rolldown-runtime-D77QRe3x.js, ./assets/react-Da5rCluR.js, ./assets/preload-helper-01dP7Tpr.js. The content hashes changed shape too, from hex (b24a9fa951034649a) to base62 (D77QRe3x, aXkEDy-Q). And with the new bundler, the webpack runtime that used to inline the chunk map into the HTML is simply gone.

This is the actual story of why the libraries "rot", and it is more structural than a regex drifting out of shape. X replaced the discovery contract, the "HTML tells you where the chunks are" assumption, and every scraper built on it broke at once.

Sixty-eight chunks and no manifest

Before hunting for a new anchor I tried to find where the URL construction lives. Three attempts, three dead ends, each worth recording because each killed a hypothesis.

First, grep every loaded chunk for the known hash. I fetched all 68 JS resources the session had loaded and searched for b24a9fa951034649:

hits: 1, ondemand.s.b24a9fa951034649a.js itself (its own sourceMappingURL comment)

Next hypothesis, a manifest endpoint. Maybe the new build fetches a JSON manifest? I listed every non-JS resource: a Cloudflare challenge script, hashflags.json, onboarding flow calls, Google sign-in bits. No manifest, nothing chunk-shaped.

Then I read the entry chunk. 21 KB, no ondemand reference, no abs.twimg.com URLs, no chunk-map object. The imports are relative (./assets/...), resolved by the bundler runtime. Dead end, unless I wanted to deobfuscate the rolldown runtime's string table, which at this point felt like scaling the wrong wall.

And then a detail I had almost skimmed past paid off. The resource timing for the login surface showed a different family entirely: main.7de7adccd6c7c8f0a.js, vendor.fd21579ee28809e7a.js, shared~ondemand.HoverCard~..., the old webpack naming, alive and well.

The login flow still ships webpack

X did not migrate everything. The logged-out landing runs the new Rolldown build, but /i/flow/login (and every authenticated page) still serves the classic webpack shell. 288 KB of it, script tags and all:

html
<script src="https://abs.twimg.com/responsive-web/client-web/vendor.fd21579ee28809e7a.js">
<script src="https://abs.twimg.com/responsive-web/client-web/main.7de7adccd6c7c8f0a.js">

And inside that document, the chunk map is exactly where the 2025 regexes expect it:

,59924:"ondemand.s"        ← chunk id
,59924:"b24a9fa951034649"  ← chunk hash (URL appends the trailing 'a')

The trailing a in the fetched filename is observed, not explained — I am marking it as such instead of guessing whether the webpack runtime appends a suffix or the map ships truncated.

One fetch of /i/flow/login gives you the key, the four SVG frames, and the chunk map. The classic index regex against the downloaded chunk still matches, because the integers it scrapes are still there:

indices: [2, 17, 20, 11]   # first selects the frame row, the rest derive the animation time

Two more facts fell out of this phase, and both come back later.

The key rotates per request. The docs describe a ~4h window; in one afternoon I collected five distinct twitter-site-verification values, one per page load, one per re-fetch. Every server response ships a fresh key. What I have not tested is binding, whether the server rejects session A's key on session B's requests. Rotation alone already forces the practical consequence, fetch materials inside the session you intend to sign for, but "belongs to" would be inference dressed as fact and I try to keep those apart.

The frames also moved into the initial HTML. The 2025 tooling intercepted loading-x-anim-* nodes with a 10 ms setInterval because they mounted and unmounted during load. Today they are simply in the document, all four, server-rendered. One less moving part.

Nine captures, regenerated offline

The Go port is ~300 lines, stdlib only: extraction, the Bézier solver, the assembly. It was the validation harness that taught me two lessons in one afternoon.

I patched XMLHttpRequest in an authenticated session and captured nine tuples of (method, url, timestamp, tid) (badge counts, flow timelines, live-pipeline calls) along with the session's meta-tag key. Then I regenerated each capture offline.

First run: 0/9. Every single one wrong.

Panic is a valid debugging strategy, but forensics is better. I base64-decoded a captured tid, XORed the body with its first byte, and read the anatomy:

decoded len: 70
trailer byte: 3                     ← the documented constant, present
key bytes match session: True       ← the 48-byte key, embedded verbatim
time value: 105300628               ← exactly ts - 1682924400, diff 0

Everything matched except the middle 16 bytes, the hash slice. So the divergence was isolated to the hash input: keyword, path format, timestamp encoding, or the animation key. With the target hash in hand, brute-forcing the format space locally is a five-minute job:

matches: ['GET!/i/api/2/badge_count/badge_count.json!105300628'
          'obfiowerehiring0a7870970a3d70a3d7080cf5c28f5c28f60cf...']

The format was exactly the documented one, keyword included, and the animation key in that input was byte-for-byte the one my Go code had derived. The algorithm was right. So why 0/9?

Because the full transaction ID can never match string-for-string across generations. Byte 0 is a fresh random XOR mask every time, so the underlying body stays identical while the encoding changes. My test compared base64 strings when it should compare decoded bodies:

go
func sameBody(a, b string) bool {
    da, db := decodeBody(a), decodeBody(b)
    return bytes.Equal(da[1:], db[1:])   // skip the mask byte
}
=== RUN   TestGroundTruth
    groundtruth_test.go:114: matched 9/9
--- PASS: TestGroundTruth

Lesson two, for completeness: the captured URLs included absolute forms (https://api.x.com/1.1/...) and the hash input takes the path only, no scheme, no host, no query string. My harness was hashing full URLs. The X client hashes what new URL(u).pathname gives you. Same bug class as the mask, comparing at the wrong layer.

Asking the gate

Byte-equality with the client is necessary, not sufficient. The server could still reject foreign values, so I asked the gate itself.

First candidate: ExplorePage, a GET GraphQL call I had captured with its full query string. Three requests from the authenticated session, no tid, garbage tid, my tid:

no tid:    200, 72 KB
garbage:   200, 72 KB
my tid:    200, 72 KB

All 200. ExplorePage is ungated; the header is checked per operation, not globally. Dead end, but it narrowed the target: I needed one of the three documented gated operations.

Getting a real SearchTimeline request meant driving the UI, which meant a small comedy of automation: filling the search combobox (the React state wanted typing, not fill()), pressing Enter three times to no effect, and finally clicking a dropdown suggestion by coordinates. Automation dignity aside, the capture landed:

GET /i/api/graphql/hyPfJYJ_XAtDYoslQc-Rgg/SearchTimeline?variables=…&features=…

Same three-way test, on the gated endpoint:

requeststatusbody
no x-client-transaction-id4040 bytes
garbage AAAA_invalid…4040 bytes
generated by my port200192 KB of results

Both controls fail closed and the generated value passes.

Writing through it

A read-only proof is a weak proof of a signing service, so I pointed the same machinery at the write path. The queryIds came from the client bundle's operation manifest (e.exports={queryId:"lI07N6Otwv1PhnEgXILM7A",operationName:"FavoriteTweet",operationType:"mutation",…}), a nice trick: any operation's current queryId without waiting for the UI to fire it.

FavoriteTweet landed ("favorite_tweet":"Done"), and the tweet page rendered the liked state for the session, surviving a reload. The quote post needed the composer once, to capture the exact CreateTweet body (2.1 KB, mostly a 35-key features map); quote posts are now attachment_url-based, not quoted_tweet_id. My raw quote with a signed tid returned create_tweet results with a real rest_id.

The accidental finding: the like also worked with no tid at all. My sample is exactly two mutations (FavoriteTweet, CreateTweet), one gated read, one ungated read; in those tests the write path passed bare and the gated read enforced. That is a probe, not a survey, and "the enforcement set is exactly the documented read endpoints" is not something two data points get to say. Sign mutations anyway; one SHA-256 is cheap insurance for the day the set grows.

The question I hadn't asked

At this point the port was validated end-to-end and I was feeling good about it, which is when I finally asked myself the question I had been avoiding: did I actually reverse the generator, or did I validate someone else's reverse against the oracle?

Honest answer: the second one. Every constant in my Go file (the epoch, the trailer, the keyword, the Bézier solver) was inherited from the 2025 writeups and confirmed by differential testing. Confirmation is not derivation. If X had changed the internals in a way that kept the output format compatible, my tests would pass and my understanding would still be 2025's. So: lab time.

The lab, and webcrack's silent failure

I refuse to deobfuscate JavaScript by staring at minified soup. The tools exist; the job is to arrange them:

  • webcrack unpacks webpack bundles into per-module files and runs deobfuscation passes
  • synchrony cleans obfuscator.io patterns
  • js-beautify, ripgrep, jq, the boring essentials

All in a Docker image (lab/Dockerfile), because tooling that lives in a container is tooling that still runs next year.

The lab earned its keep immediately, with a failure mode I want on the record: webcrack exited 0 and unpacked nothing. No error, one beautified file, zero modules, a silent lie. Reading webcrack's matcher source found it. X's chunk maps use shorthand object methods ({812117(e){…}}), which Babel parses as ObjectMethod, and unpack-webpack-chunk only recognizes ObjectProperty with function-expression values. The fix is a 40-line AST pass (lab/preprocess-object-methods.js) rewriting methods to equivalent function properties, validated on a minimal repro before touching the real bundles. With it:

main.7de7adccd6c7c8f0a.js   557 modules
vendor.fd21579ee28809e7a.js 416 modules
ondemand.s.b24a9fa…a.js     1 module

One module in that last chunk. The generator.

Three hundred strings and one joke

The deobfuscated module is ~390 lines, and nothing meaningful in the shipped version is a literal. One grep at a time:

sh
$ for s in obfiowerehiring r-4uwx00 1682924400 'name^=tw' \
           sha-256 cubic-bezier createDataChannel; do
    printf '%-18s ' "$s"; grep -ac "$s" ondemand.s.js; done
obfiowerehiring    0
r-4uwx00           0
1682924400         0
name^=tw           0
sha-256            0
cubic-bezier       0
createDataChannel  0

The scheme, reconstructed standalone in Python (the chunk itself never executed): a 300-entry string array; a self-invoking shuffler that rotates it with push(shift()) until a checksum over ten decoded entries equals 683073, converging after exactly 158 rotations; then r(index, key) does array[index-365], base64 with obfuscator.io's shuffled alphabet (lowercase run first, so your stock base64 decoder eats garbage), UTF-8, then textbook RC4 keyed by the second argument. Most call sites hide behind five-argument shadowed wrappers (function d(n,W,t,o,e){return r(o-51-421,W)}), resolved by brute-testing every same-named definition for an in-range index and printable output. The full table is dumped in data/deobf/strings-ondemand.txt, every line from actual decoding.

The keyword is the detail that justified the whole exercise: obfiowerehiring does not exist even inside the decoded table. It is assembled at runtime from three separate RC4 calls:

js
r(380,"3K&a") + r(650,"yvk8") + r(488,"q7To")   =   "obfio" + "wereh" + "iring"

Same pattern for the crypto: "sha-2"+"56", "crypt"+"o", "subtl"+"e", "diges"+"t". Split-string assembly means no single decode ever produces a searchable artifact. Any investigation that stops at "grep the bundle" concludes, wrongly, that the keyword changed or vanished. Mine almost did.

With the string layer gone, what remains is control-flow flattening: one arithmetic op per table entry (o.korHf(n, W) { return n * W }), always-true guards (if (o.prUBV(o.eImkw, o.eImkw))) wrapping decoy else-branches, and a destructuring alias wall for the globals. Rename thirteen bindings, delete the dead branches, and the module is almost readable.

The generator, read honestly

The first thing reading the real code fixed was my own folklore. Every writeup says the client "simulates the loading animation", and every port, mine included, implements a binary-search cubic-Bézier solver to do it. The real code does something much more audacious:

js
const H = () => { const el = document.createElement("div");
                  document.body.append(el);
                  return [el, () => el.remove()] };
 
// inside the derivation:
const [el, cleanup] = H();
const anim = el.animate(F(frameRow), 4096);   // Web Animations API, real keyframes
anim.pause();
anim.currentTime = Math.round(frameTime / 10) * 10;
const style = getComputedStyle(el);
animKey = Array.from(("" + style.color + style.transform).matchAll(/([\d.-]+)/g))
            .map(m => Number(Number(m[0]).toFixed(2)).toString(16))
            .join("").replace(/[.-]/g, "");

There is no animation math in the client. It builds CSS keyframes from the SVG frame row (two colors, rotate(0deg) → rotate(Ndeg), easing: cubic-bezier(…) with control points solved from frame bytes), runs a real animation on a hidden div via the Web Animations API, pauses it at the derived time, and reads the interpolated state back from getComputedStyle. The browser's animation engine is the computation. Every port's Bézier solver exists to emulate the browser, and the ubiquitous round(frameTime/10)*10 I had copied without understanding is just millisecond rounding for currentTime. Reading the generator turned folklore into mechanism.

The final assembly, deobfuscated to honesty:

js
time = Math.floor((Date.now() - 1682924400 * 1000) / 1000);
hash = sha256(`${method}!${path}!${time}` + "obfiowerehiring" + animationKey);
out  = [rand()].concat(keyBytes, timeLE(4),
                       hash.slice(0, 16).concat(N),   // N: optional WebRTC bytes (finding #2)
                       [3])
         .map((v, i, a) => i ? v ^ a[0] : v);
tid  = base64(out).replace(/=/g, "");

The epoch, the trailing 3, the joke keyword, all constants in this one module. The "indices" the community regexes out of the chunk are hardcoded key-byte positions in the derivation: key[2] % 16 selects the frame row, (key[17]%16)·(key[20]%16)·(key[11]%16) derives the animation time.

Four findings I could not find in any writeup

First, the selectors. The generator reads the key via [name^=tw], any meta whose name starts with tw, first match wins; twitter-site-verification just happens to be it. The frames come from the CSS class .r-4uwx00, an atomic utility class, not the loading-x-anim ids every implementation selects by. One key byte feeds both derivations: key[5] % 4 selects which of the four elements supplies the SVG path that gets parsed, and key[5] % 8 indexes the SDP in the WebRTC component below. Same elements today. But a class rename, or one new tw* meta tag ordered first, breaks the id-based world silently while the real client keeps working. When the next "X broke my scraper" wave happens, check these first.

Second, there is a WebRTC component. Before hashing, the module spins an RTCPeerConnection with a data channel, fishes bytes out of the local SDP (indexed by key[5] % 8 and key[8] % 8, fallback "4"), and appends them after the hash slice. Failures are swallowed with .catch(() => 0). My port omits it, like every port, and in my tests the server accepted the result without it. The ground-truth captures pin this down further: all nine decode to exactly 70 bytes, mask plus key plus time plus hash plus trailer, with zero room left for N, so the real client emitted an empty N in every capture. The absence is on the client side, not merely tolerated by the server; nine captures from one session is a small sample, and the escalation-lever reading is unchanged.

Third, there is a kill switch, and an error channel. main.js, the plaintext bundle, is where the header gets attached, and it is chatty about it: the generator is lazy-loaded as r.e(59924).then(r.bind(r, 208932)) (proof by code path that ondemand.s is the generator, not a config file), gated behind a feature flag named rweb_client_transaction_id_enabled. When generation throws, the failure is tunneled through the header itself: btoa("e:" + err). The server receives a base64'd error message where the transaction id should be. A kill switch and a debug channel, sitting in plaintext, invisible unless you read the loader.

Fourth, the indices are the constants the derivation hangs on. They are not configuration, not derived, not versioned; they are integers in the source of module 208932. When X wants to rotate the derivation, they ship a new ondemand.s with different integers, and the world's regexes starve. Which is precisely what the 2026 breakage was: not a protocol change, a constant reshuffle.

Wrapping up

The full journey, in the order it happened: the documented regex died on a landing page that had quietly migrated to Rolldown; sixty-eight grepped chunks and a missing-manifest hunt later, the webpack shell turned up alive in the login flow with the chunk map intact; the Go port validated 0/9, which decoded into a lesson about XOR masks and comparison layers, then 9/9. The gate itself answered 404/404/200 on SearchTimeline, and the write path (like, quote) landed ungated. Only then, after calling my own bluff, did I actually read the generator, through a lab whose star tool failed silently until an AST patch taught it X's dialect of webpack, a 300-string RC4 table reassembled in Python, and 390 lines of control-flow flattening worth reading.

What stays with me is how little of this was crypto. One SHA-256, one XOR byte, one joke string split three ways. The defense is entirely in packaging: a bundler migration reset every regex-based scraper on X's schedule, not the researchers'. The antidote is equally unglamorous, a container with the right tools, forty lines of AST patching, and the patience to read 390 lines once instead of patching a regex every quarter. This post documents the WebRTC component; it does not ship an implementation of it.