Kasada

How I broke Kasada, and how it works step by step

The first time Kasada blocked me, back before I'd written a word about it, I assumed I'd broken my own scraper.

That's the honest version. I got a 429 with an empty body, no challenge page, no CAPTCHA, nothing that looked like a decision had been made about me. So I did what you do: checked my headers, checked the proxy config, checked whether I'd fat-fingered the URL. All fine. Ran it again. Still 429.

Then I did the thing everyone does and threw a browser at it. Headless Chrome, stealth patches, residential exits, request rate slowed to something a distracted human might produce. Still 429. At that point I stopped debugging my code and started reading, because the problem clearly wasn't on my side of the wire.

What follows is what I learned, including a few things I had wrong for longer than I'd like. If you've been fighting this and losing, some of it will be uncomfortably familiar.

What I did and didn't do here

Worth setting expectations before you read three thousand more words.

The observable half of this I worked through myself: the request handshake, how the headers behave across a session, what happens when you replay tokens, how fast the challenge script rotates, how differently two Kasada sites can be configured. All of that you can reproduce in a browser with the network panel open, and I'd encourage you to, because watching it beats reading about it.

The bytecode internals are a different matter. Pulling apart a virtualized obfuscator properly is months of specialist work and I haven't done it. What I've done is read the people who have, mainly notemrovsky's teardown and the nullpt.rs devirtualization, then work out what their findings mean for anyone doing this at a practical level. Where I'm relaying their work rather than mine, I say so.

I'd rather be boring about that than pretend. Plenty of writeups in this space blur the line, and I've stopped trusting the ones that do.

How does Kasada work?

Kasada works by shipping a custom bytecode VM to the browser, running roughly 400 fingerprint probes inside it, and demanding a hashed proof of work before it issues a session token. The interpreter is readable; the program it runs is not. Failed clients get a 429, never a CAPTCHA. Start by watching the four requests it fires.

That last sentence is the advice I wish someone had given me on day one. I spent my first afternoon staring at an obfuscated script when the answer to most of my questions was sitting in the network panel in plain sight.

The design goal is economic rather than forensic, and once that clicked the rest made sense. Kasada isn't especially interested in proving you're a bot. It wants each valid token to cost real CPU time on your hardware, and it wants the code that mints that token to rot on a schedule so nobody can amortize the reverse-engineering effort across builds.

Two bills, in other words. The proof of work taxes your volume. The rotating VM taxes the tooling you built to pay the first tax.

I find that genuinely clever and I resent it a little.

What is Kasada, and who uses it?

Kasada is a bot defense vendor founded in Sydney in 2015 by Sam Crowther. The product sits in front of web, mobile, and API traffic and decides on the very first request whether you're a real browser, which is a different posture from systems that let you in and score your behavior afterwards.

The no-CAPTCHA thing isn't a gap in the product. It's the position. Automated clients get blocked outright instead of being handed a puzzle, which means there's nothing for a solving service to sell against it. Whatever you think of the company, that's a coherent argument and most of its competitors can't make it.

Two of their own announcements are worth your time, because they describe the design this article takes apart. The V2 launch in March 2021 claimed a fifteenfold increase in client interrogation sensors and introduced the custom interpreter the client logic now runs inside. A 2022 release aimed at solver services added randomization across the scripts, the detection logic, and the encrypted payloads, with the stated goal of making solver upkeep expensive enough to kill the business model.

Read that second one before anything else written about Kasada. The rotation isn't a side effect of obfuscation. It's the product, and they'll tell you so themselves.

Deployments you can identify publicly include Nike, Foot Locker, Kick, Twitch, and Sportsbet, whose head of cyber security shows up in Kasada's own press material. If you want the vendor in context, we ranked the field in our rundown of anti-bot solutions.

The Kasada request flow: which piece does what

This is where I lost two days early on, and I'm still slightly annoyed about it.

Search for Kasada writeups and you'll find posts saying the protection is delivered as p.js, an obfuscated file containing the interpreter. You'll find others giving that job to ips.js. Both can't be right, and I picked the wrong one to start with, which meant I spent a while hunting for fingerprinting logic in a file that doesn't contain any.

Each piece has exactly one job.

Piece What it is When you see it What it produces
/fp A bare HTML page in a hidden iframe. Initializes the KPSDK object and posts a message to the parent. First contact, no valid token The pass-through token later sent as x-kpsdk-im
p.js The initializer. A bytecode VM too, but an older and much simpler generation. Loads first, from the /fp page Proof-of-work parameters, the per-domain seed phrase, and the URL for ips.js
/mfc A config fetch that runs in parallel with the script load Alongside ips.js Difficulty, sub-challenge count, seed suffix
ips.js The payload generator. Interpreter, encoded bytecode, every probe, the crypto. Fetched by p.js, fresh URL on every load The encrypted fingerprint blob
/tl The token endpoint. A POST with a binary body. Once the VM finishes x-kpsdk-ct and the session cookie, on success

If you're going to open one of these in a debugger, open p.js. Same architecture, maybe a tenth of the difficulty, and nobody starts there because it's the smaller file and looks less important. It's a far better place to learn what you're looking at than the file everyone jumps to.

The header family, and the mistake I made

The headers get conflated as badly as the scripts do.

x-kpsdk-ct is the session token: expensive to mint, reusable for the life of the session. x-kpsdk-cd is the proof-of-work answer, cheap to compute and single-use, so the server rejects a repeat. x-kpsdk-st is a server timestamp the client has to build on, which kills precomputed answer stockpiles. x-kpsdk-v pins the SDK version. x-kpsdk-dt carries timing data describing how long each phase supposedly took in the browser, and it matters more than its name suggests.

Now the confession. For a long stretch, our own Kasada bypass guide told you to treat the presence of x-kpsdk-ct as evidence you'd been blocked. That's backwards, and I wrote it.

CT on a response tells you Kasada is in front of the site. It tells you nothing about whether you passed. The verdict is the status code: a 200 carrying a token means you're through, a 429 or 403 alongside challenge HTML means you're at the start of the handshake rather than the end of it. The header is a vendor fingerprint. The status is the result.

I mention it because the consequence is nasty. A detection script with that logic reports failure on requests that actually succeeded, so you sit there tuning fingerprints against a signal that was already green. Both of our articles are fixed now, but if you copied that snippet from us at some point, go check it.

One page load, all the pieces at once

Open a Kick stream with DevTools running and filter the network panel by 149e9513, a build identifier that turns up across Kasada tenants. Four rows, in order.

p.js lands first, sets up the environment, pulls the proof-of-work parameters, and fires /mfc. /mfc comes back with difficulty and sub-challenge count. ips.js loads next, and its URL is different from the one you saw thirty seconds ago because the version and a per-load token are baked into the query string. Then one POST to /tl with a binary body, and if the server likes it, a {"reload":true}, an iframe reload, and your token.

Four requests. Everything difficult about Kasada is inside the third one.

The six layers of a Kasada check

Here's where I hand over to the people who did the deep work, because this section is mostly me explaining their findings and what those findings cost you in practice.

A valid x-kpsdk-ct is hard to produce because six mechanisms sit on top of each other, and solving any five of them gets you nothing.

1. The bytecode VM

Open ips.js expecting JavaScript and you get three things glued together: a decoder, a very large encoded string, and a virtual machine that executes what the decoder produces.

The VM is register-based. A dispatcher reads an integer from the bytecode array, uses it to index a table of small handler functions, and calls one. Each handler is a few lines: read two operands, write a result, advance the pointer.

The detail I liked most is that every instruction runs inside a try/catch, and it isn't defensive coding. The catch block is how the VM implements exception handling and scope unwinding for the guest program. Whoever designed that deserves a raise, and I say that as someone it has personally inconvenienced.

Stripped of obfuscation, the loop is the textbook shape:

// Shape of the dispatcher. Names here are mine; the real ones rotate.
function run(state) {
  while (true) {
    const opcode = bytecode[state.ip++];   // fetch
    const handler = handlers[opcode];      // decode
    try {
      if (handler(state) === null) break;  // execute, null = halt
    } catch (err) {
      unwind(state, err);                  // the VM's exception path
    }
  }
}

You can read that in thirty seconds and learn nothing about what Kasada collects. The interpreter is the vehicle; the program is data. That's the entire point of virtualization, and it's why tooling that works against Akamai's sensor script doesn't transfer here.

What it costs you: in one documented build the handler table held 172 entries, and many were fused, meaning a single opcode performed three or five property assignments rather than one. Fusion quietly breaks any disassembler that assumes one opcode equals one operation, because the operand counter drifts and everything after the first fused instruction decodes as garbage.

2. The time-locked decoder

This is the one that would have saved me an afternoon if I'd known about it earlier.

Before any of that runs, the encoded string has to become integers, and the decoder won't do it outside a time window. The seed comes from the current clock divided by a large constant, carving time into windows of roughly five hours. Those seed digits fold into every decoded value, so a wrong window gives you wrong output all the way down. The script tries the current window plus one either side to survive clock drift, then runs a checksum over the first handful of values and bails if it doesn't match.

The arithmetic is easier to see than to describe:

import time

DIVISOR = 18_000_081   # value rotates between builds
MULT = 14

def window_seed(unix_ms):
    return round(unix_ms / DIVISOR) * MULT

now = int(time.time() * 1000)
print(window_seed(now))                      # current window
print(window_seed(now - 6 * 3600 * 1000))    # six hours ago: different seed

A script you saved this morning is undecodable this evening. I'd captured a few for later analysis, came back the next day, and spent an embarrassing amount of time convinced my own tooling had broken. It hadn't. The file had expired.

Every parameter in this stage rotates between builds: the alphabet, the radix, the divisor, the multiplier, the checksum constant. Anything you hardcode is a liability with a countdown on it.

3. The opcode permutation

This is the layer that quietly ruins people, and I haven't seen it covered in any of the "how Kasada works" posts currently ranking.

The handler table starts as an identity array. During bootstrap, before the real program runs, the VM shuffles it with a seeded Fisher-Yates pass. Afterwards, opcode 42 no longer means handler 42. It means whatever landed at index 42 after the shuffle.

// Seeded shuffle, the shape used to permute the handler table.
function shuffle(arr, seed) {
  const out = arr.slice();
  let s = seed;
  for (let i = out.length - 1; i > 0; i--) {
    const r = Math.sin(s++) * 10000;
    const j = Math.floor((r - Math.floor(r)) * (i + 1));
    [out[i], out[j]] = [out[j], out[i]];
  }
  return out;
}

Change the seed by one and the mapping changes completely. Every build ships different seeds, so a disassembler with a hardcoded opcode table produces output that looks plausible and is entirely wrong. That's worse than obvious garbage. Obvious garbage you notice in a minute; plausible garbage you believe for a day.

What it costs you: validation turns out to be cheap, which is the one piece of good news in this section. Replay a candidate permutation and count how many instructions decode before the operand stream falls apart. A correct permutation walks tens of thousands cleanly. A wrong one dies inside fifty. There's no middle ground, so you can search for the answer rather than deduce it.

4. The fingerprint template

Now the part all that obfuscation exists to protect, and the part that changed how I think about stealth patching.

The script allocates an array of 428 slots and fills 427 of them with probe results, spread across twenty batch functions that each run a group of probes in parallel.

What they read is what you'd expect from a serious fingerprinting suite, and then a lot more. Screen and viewport geometry from the top window and the iframe. WebGL vendor and renderer strings, uniform limits, aliased line width ranges. Audio context sample rate, channel count, output latency. Codec support through canPlayType across a long list of media types. Timezone from two different sources. Emoji rendering. Heap size.

Two things about that collection matter more than the list.

Most values get read twice, once from the top window and once from the hidden iframe. navigator.webdriver from both isn't a duplicate, it's a mismatch check. Patch the window and forget the iframe and you've manufactured a signal that wouldn't exist if you'd patched nothing at all. That reframed stealth work for me: a half-applied patch is worse than no patch.

There's also a cluster of probes reading Element.prototype.attachShadow, its name, its length, and its toString output. Those exist to catch runtimes that patched shadow DOM APIs on the way to hiding something else. Sloppy patching has its own fingerprint.

Then the part that makes the values hard to use even once you can read the program: between every batch, the template array gets shuffled. Twenty batches, twenty shuffles, each batch writing into post-shuffle positions. Slot 234 holds one probe's output during batch 12 and a different probe's during batch 6.

Kasada knows the mapping because Kasada compiled it. From outside you can identify what each probe reads and replay the shuffles, but you still need real values from a real browser to put in the slots. Static analysis gets you the schema and never the data.

Notice what isn't on the list, either: mouse paths, scroll cadence, click coordinates. Those are session-scoring signals that accumulate after you're through the door. The template is a snapshot of your environment taken before you've done anything, which is exactly why Kasada can block on request one while behavioral systems need a few seconds of you being yourself.

What you can do with this: the useful version for most people isn't reconstruction, it's auditing. Instrument the page, log property reads on navigator, screen, and the WebGL context, and diff a stock Chrome profile against your automation profile. Every difference is a slot where you look wrong. The WebGL and audio probes carry the most signal per byte, so start there.

5. Proof of work, and the timing envelope

The proof of work is the least exotic layer here, which surprised me. It's SHA-256 chaining against a seed built from the challenge token, a work timestamp, a random solve ID, and a per-domain seed phrase that lives in p.js. Hash, check the leading bits against a difficulty threshold, increment a nonce, repeat, once per sub-challenge, chaining answers forward.

Difficulty and sub-challenge count arrive from /mfc, so an operator can turn the dial without shipping new code. That's why two Kasada sites can feel like two different products.

A real browser pays this in the low tens of milliseconds. The server verifies in microseconds. That asymmetry is the whole pitch, and it's the same trade Ticketmaster's queue makes with its own PoW, which we pulled apart in the EPSFC proof-of-work solver writeup.

The part that took me longest to accept is the timing envelope. Remember x-kpsdk-dt, the header carrying phase timings? A native implementation running outside browser overhead solves the same challenge far faster than Chrome does, and a proof that arrives implausibly quick is a signal in its own right. Correct and too cheap still reads as automation.

Every other system I've worked against rewards making your client faster. This one punishes it, and I got that wrong instinctively for a while before the penny dropped.

What you can do with this: measure your own browser before anything else. Time the gap between the ips.js response and the /tl POST across twenty page loads on a stock profile, on hardware you actually own. That distribution is your envelope, and anything outside it is a tell no matter how correct your answer is.

6. The payload encryption

Once the template is full, two static strings get prepended, a metadata slot gets injected with timing information, and the array is encrypted before it goes to /tl.

The cipher is XTEA at 32 rounds, which is unremarkable. The chaining is where it gets odd. Rather than walking blocks in order the way CBC does, the script keeps a small queue, picks one at a pseudo-random index derived from the previous ciphertext, encrypts it, and replaces it with the next unprocessed block. Blocks come out non-sequential.

The key is more interesting than the cipher. It isn't stored as a string. It's an integer array in the bytecode whose first element selects which of several expansion functions to run, and each of those grinds the remaining integers through bit rotations, position-dependent offsets, and magic constants to produce the bytes.

What it costs you: this layer is why a partial VM gets you nothing. Producing the payload means executing enough of the bytecode to derive the key, so every anti-analysis layer above has to be beaten first. The encryption is what makes the rest of it load-bearing.

How to study a Kasada deployment without burning a month

If you're here about one specific site rather than the system in general, the order matters more than the technique. This is roughly the order I'd use now, which is not the order I used the first time.

1. Watch the network before you open a debugger. Record which endpoints fire, what x-kpsdk-v says, whether /mfc is present, what difficulty it returns, whether tokens survive across requests. Two hours of observation told me more than two days of squinting at obfuscated code, and I did those in the wrong order.

from playwright.sync_api import sync_playwright

def watch(url):
    with sync_playwright() as p:
        page = p.chromium.launch(headless=False).new_page()
        page.on("request", lambda r: (
            print(r.method, r.url[:110])
            if any(k in r.url for k in ("/tl", "/fp", "/mfc", ".js"))
            else None))
        page.on("response", lambda r: [
            print(f"  {k}: {v}") for k, v in r.headers.items()
            if k.startswith("x-kpsdk-")])
        page.goto(url, wait_until="networkidle")

watch("https://example-protected-site.test/")

One page load gives you the deployment's shape. The header dump alone tells you which generation you're dealing with.

2. Measure the rotation clock. Fetch ips.js on a schedule, hash the body, log when it changes. Some tenants rotate on a slow cycle; others change the URL parameters every single load while the underlying build sits still for days. Those are completely different problems and you can't tell them apart without the log.

3. Check whether you need the VM at all. The step I skipped, so I'll be emphatic about it. On weaker deployments people mint the expensive token with a real browser and only reimplement the cheap per-request part, because that's lightweight work. Public research on Kick and Twitch says so directly: those sites were being automated at scale without anyone holding a full solver. Find out which side of that line you're on before committing to the hard path.

4. Resist building the general solution. Two IPs and a retry loop beat a 400-line rotation framework you'll rewrite next month. Same principle here: a browser that mints tokens and a queue that consumes them is unglamorous, it works, and it survives a build rotation that would flatten a reimplementation. The hands-on setup is in our Kasada bypass guide.

5. Then, if you still want to, open p.js. Not ips.js. p.js first, always.

Kasada in practice: three kinds of deployment

The single most useful thing I learned is that "is Kasada hard" is the wrong question. Tenants configure it so differently that the vendor name barely predicts the difficulty.

Streaming platforms

Kick and Twitch both sit behind Kasada, and both are weak deployments by the assessment of people who've reversed the whole stack. Low difficulty, patchy enforcement, and in practice people mint the session token with a real browser and handle the per-request piece programmatically. Our guide to scraping Kick.com reflects that rather than pretending a solver is table stakes.

Drop sites

Nike and Foot Locker are the other end. High difficulty, aggressive IP reputation, token lifetimes short enough that stockpiling buys you nothing. The reputation layer is network-wide too, so an IP that misbehaved against one Kasada tenant arrives damaged at the next one, even on first contact.

This is where the proxy layer stops being a checkbox. Datacenter ranges are effectively dead here, and residential or mobile exits with clean history are the floor rather than an upgrade. It's most of why we built Roundproxies around pool hygiene instead of raw pool size.

Ticketing and betting

Sportsbet and similar operators run high difficulty plus per-session escalation, so the cost climbs during a session that looks automated rather than sitting fixed at entry. A solve rate that looks fine for two hundred requests degrades after that. Benchmark on a short run and you'll get a number that doesn't survive contact with a real workload, which is a mistake I'd rather you make on my word than your own.

Where people get Kasada wrong

Most of these I got wrong first, which is how they made the list.

Read the protocol before you read the bytecode

Going straight to ips.js because it's the interesting file is the most common failure and it was mine. The four-request handshake, the header family, and which tokens are reusable answer nearly every practical question. The VM answers a narrower one, and only for as long as the build lives.

Why a fresh IP with the same fingerprint changes nothing

This one confuses people who've done the proxy work properly and still get blocked. A suite reading 400-odd values from both window and iframe sees the repetition regardless of where the packets came from. Same WebGL renderer, same audio latency, same screen geometry, arriving from thirty residential exits inside a minute, is a cluster, and clusters are what reputation scoring exists to find.

The JA3 and TLS fingerprint underneath is decided before a single line of JavaScript runs, so a Chrome user agent riding a Python handshake has lost before ips.js even loads.

Speed is evidence

Worth repeating because it's so counterintuitive. A correct answer produced faster than a browser could produce it is itself a signal, which is why x-kpsdk-dt exists. Most detection systems reward efficiency. This one penalizes it.

Treat CD as disposable

The per-request token is single-use and the server tracks what it's seen. Pull tokens off a completed request and the cheap half is already spent. Every request needs a fresh one computed against the current server timestamp, which is the design's answer to replay.

Where none of this helps

The honest limit. Understanding Kasada's internals doesn't fix a burnt IP pool, and on a hardened tenant it does nothing at all if your device profile is thin. You can reproduce the bytecode perfectly and still fail because the values going into the template came from a headless Chrome with a software WebGL backend and no audio device.

The VM is the part that gets written about because it's fun to write about. The things that decide whether you get a 200 are usually the boring ones: exit quality and device realism. When I'm stuck now, that's where I look first, and it's where I should have looked the first time.

Why this post stops where it does

There's a version of this article that publishes the decoder parameters, the XOR keys, the per-domain seed phrases, and a working solver. I'm not writing it, and not out of squeamishness.

Detail at that level has a shelf life measured in weeks, so it makes terrible reference material. It also turns an explanation of how a security control works into the control's defeat, which is a different thing with a different audience.

What's here is the architecture: the request flow, the virtualization, the time lock, the permutation, the fingerprint template, the proof of work and its timing constraint. Enough to reason about the system, audit what a site collects about you, decide whether a project is worth attempting, and understand why the last three things you tried failed. The research linked above goes further if you're set on it, and the people who did that work deserve the traffic more than I do.

FAQ

Why does Kasada return 429 instead of showing a CAPTCHA?

A CAPTCHA is a second chance, and Kasada's position is that automated clients shouldn't get one. A puzzle tells you what you failed and lets you retry with the same infrastructure; a bare 429 gives you no gradient to climb. It also leaves solving services with nothing to sell, which removes a whole commercial layer from the attacker's side.

What's the difference between x-kpsdk-ct and x-kpsdk-cd?

x-kpsdk-ct is the session token, minted once from the full fingerprint payload and reusable until the session expires. x-kpsdk-cd carries the per-request proof-of-work answer, is cheap to produce, and is single-use. One certificate, many disposable receipts.

Can you get past Kasada with a headless browser?

Depends entirely on the tenant. Weak deployments fall to a well-configured real browser on clean residential exits. Hardened ones read the iframe-versus-window mismatches, the WebGL and audio profile, and the timing envelope, so a stock headless Chrome fails several checks before the proof of work is even attempted. The tooling side is in our Kasada bypass guide.

How often does Kasada rotate its script?

It varies per tenant, and two things rotate separately. The build changes on the operator's cycle; the script URL and its parameters change on every single page load regardless. On top of both, the encoded payload expires in windows of roughly five hours, so a captured script stops decoding whether or not the build changed. I learned that one the slow way.

What websites use Kasada?

Publicly identifiable deployments include Nike, Foot Locker, Kick, Twitch, and Sportsbet. The fastest way to check any site yourself is to open the network panel and look for response headers starting with x-kpsdk-, which appear whether or not you were blocked.

Is Kasada a WAF?

Not in the usual sense. A web application firewall filters requests against rules aimed at injection, traversal, and similar attacks. Kasada targets automation specifically, through client interrogation and a proof-of-work challenge rather than payload inspection. Plenty of sites run both, with Kasada in front of the CDN and a WAF behind it.

Is Kasada harder than Cloudflare or DataDome?

Structurally yes, on the client side. DataDome and PerimeterX use a VM for a field or two in their fingerprint; Kasada, Shape, and Cloudflare put the whole program inside one. That's a different order of work and it doesn't transfer between builds. In practice it depends on the deployment, and a weak Kasada tenant is easier than a hardened Cloudflare one. Rank the deployment, not the vendor.

Wrapping up

The mental model I ended up with: Kasada is trying to charge you rather than identify you. The proof of work charges per request, the rotating VM charges per build, and the per-tenant configuration means you pay both bills separately for every site you touch.

So the first question on any Kasada target isn't whether it can be solved. It's what this particular deployment costs and whether the data justifies it. Run the network observation from step one before you decide anything else, which is the advice I'd give my past self if I could get a message to him about two days in.

For further reading, the deepest public work is notemrovsky's teardown of the Kasada VM, the nullpt.rs devirtualization of Nike's protection, and Tim Blazytko's writeup on disassembling VM-based obfuscators, which is the theory the rest of it sits on.ow to bypass Kasada.