RQ4-S: detecting cookie-reuse handoff in session-level header analysis
The cookie-reuse handoff attack defeats per-request bot detection without sophisticated tooling. A real browser visits the protected site, solves whatever JS challenge the WAF presents, receives a session cookie that says "this client is trustworthy," and then exports that cookie to a bot. The bot makes its subsequent requests carrying the legitimate cookie, and the per-request defenses see what looks like a returning trusted user. Each individual request passes inspection; the attack is invisible at the per-request layer.
RQ4-S (the session-level extension of the RQ4 specification at rq4.dev, Section 6) catches this attack by correlating per-request RQ4 vectors across requests sharing the same session cookie. The browser phase of the session produces RQ4 vectors consisting only of valid (v) or indeterminate (-) characters; the bot phase produces vectors containing impossible (x) characters because the bot can't replicate the browser's contextual header dispatch perfectly. The transition from clean to impossible mid-session is the signal.
This post walks through the attack, the detection state machine, what it catches, and what it doesn't.
What this looks like in practice
Common scenario: a mid-size B2B SaaS, ~$50K MRR, runs Cloudflare's free JS challenge on the signup form. The challenge has stopped about 70% of bot signup volume since it was enabled, a significant win. But signup-to-paid conversion is still suspiciously low: 30% of signups never log in a second time, use temp-mail addresses, and consume the free-trial credit budget in a single batch before disappearing.
Investigation: write a small script that logs cf_clearance cookie values + per-request IP + RQ4 vector for every signup attempt over 24 hours. Run it.
The pattern: at 11:47am, a single request comes in from a residential IP, solves the Turnstile challenge in a real Chromium (the request bears all the JS-evaluated cookies, the JS-execution timestamp is plausible). Receives cf_clearance cookie. Browser session ends.
At 11:52am, a burst of 1,247 requests arrives. Different IPs (residential pool, 800+ unique IPs). Different User-Agent strings (the bot rotates UAs across requests). One thing in common: every single request carries the exact same cf_clearance cookie value the legitimate browser received 5 minutes ago.
Whoever ran the challenge once is now selling/replaying the cookie at scale. Each individual request looks legitimate to Cloudflare: valid cookie, in-window, IP from a residential pool, header sets that don't fail Cloudflare's per-request checks.
The fix is RQ4-S: track header consistency across the requests sharing each cookie. The original browser produced clean RQ4 vectors (vvvv); the replaying bots produce xxvx because they reuse navigation-style headers on what should be post-navigation requests. The transition from clean to impossible mid-session is detected on request #2 of each handoff batch.
Deployed the RQ4-S check in a Cloudflare Worker in front of the signup endpoint. The 11:52am burst the next morning never gets past the second request, every replay attempt fires rq4s_transition and gets a 403. Real users (carrying their own freshly-issued cookies) pass through.
The temp-mail signup rate drops from ~30% to ~4% over the following week. The remaining ~4% is presumed to be actual humans using temp-mail for their own reasons (anonymity, throwaway accounts, etc.), that's a different problem and not one bot-detection alone can solve.
The attack
The cookie-reuse handoff has three steps:
Browser phase. An attacker visits the target site in a real browser (Chrome, Safari, Firefox). The site's WAF (Cloudflare's JS challenge, Imperva's reese84, Akamai's sensor-data challenge, F5 Shape's signed messages) serves a JavaScript challenge. The browser executes the JS, produces a proof-of-execution value, and is granted a session cookie. The cookie is valid for 30 minutes to 24 hours depending on the WAF.
Cookie export. The attacker extracts the cookie. Browser extensions, manual copy-paste from devtools, or a headless Chrome script that runs the challenge and exports the result. Selling these cookies is a real market, pre-solved session cookies for
protected-site.comgo for $0.10-$2 each on scraper forums.Bot replay phase. The attacker's bot, typically curl_cffi or a custom HTTP client, makes requests to the protected site carrying the exported cookie in the Cookie header. The WAF sees a session it issued, in the cookie's TTL, and lets the request through. The bot scrapes pricing, harvests data, runs credential-stuffing, posts spam, whatever the original objective was. Each individual request looks legitimate.
The attack works because per-request detection has nothing to compare against. The first request with a bot-looking pattern is treated as a new visitor (issue new challenge, request the JS execution). With the cookie, that first inspection is skipped, the request is "returning user," verified.
What RQ4-S looks for
The RQ4 fingerprint captures four dimensions of header consistency per request: Mode, Upgrade, Identity, Transfer. Each dimension produces v (valid combination per the Fetch spec), x (impossible combination, browsers cannot produce this), or - (indeterminate from available data). A real browser session produces vectors like vvvv, vv-v, or occasionally --v-. A bot that reuses navigation-style headers on cors-style requests produces vectors like xxvx (impossible on three of four dimensions).
The cookie-reuse handoff produces a session signature that looks like:
Request 1 (real browser, GET /): rq4 = vvvv → session is clean
Request 2 (real browser, POST /api): rq4 = vvvv → session is clean
[cookie exported to bot]
Request 3 (bot, POST /api): rq4 = xxvx → TRANSITION detected
Request 4 (bot, POST /api): rq4 = xxvx → continued bot activity
A session that produced only clean vectors for two requests cannot legitimately produce xxvx on its third. Real browsers don't change their header-dispatch logic mid-session. The transition is the cookie handoff.
The state machine
Per the open RQ4 v2.0 specification, Section 6:
State:
clean: bool (true = only v/- vectors observed)
count: int (total requests in this session)
flagged_at: int (request number when clean → flagged, if any)
For each request with cookie C and RQ4 vector V:
state = get_or_init(C)
state.count += 1
has_impossible = "x" in V
if has_impossible and state.clean and state.count > 1:
signal = "rq4s_transition" # the smoking gun
state.clean = false
state.flagged_at = state.count
elif not state.clean:
signal = "rq4s_continued" # continued bot activity on flagged session
else:
signal = none # request is consistent with session state
The count > 1 requirement on the transition rule is intentional: a bot's very first request on a fresh session cannot be a "transition" because there's no prior clean history to transition from. That case is handled by per-request RQ4 analysis, the request still produces an x-bearing vector that flags at the per-request layer. RQ4-S specifically catches the handoff pattern where a session was clean and now isn't.
A reference simulator for this state machine verified seven canonical scenarios: pure browser sessions produce no signal, clean-then-bot transitions fire, continued bot activity fires, bot-from-request-one doesn't fire (no prior clean history), bot-rotating-cookies produces no session signal (each cookie is a fresh session of count=1), and the documented evasion ceiling (bot maintaining vvvv throughout) passes through. Implementing the algorithm above against these test cases is a one-afternoon project; the spec is small.
What this catches
The cookie-reuse handoff is one of the most common bypass patterns for JS challenges. Empirically observed against:
- Cloudflare's free-plan JS challenge,
cf_clearancecookie. We've observed handoff attacks in the wild. - Imperva Incapsula's reese84 challenge,
reese84cookie. Same pattern; the challenge solution is portable across clients. - Akamai's sensor-data challenge, session cookies including
_abck. Handoff has been documented publicly by scraping researchers. - F5 Shape Security signed-message challenge, also vulnerable to handoff for windows where the signed message hasn't expired.
In each case, the WAF assumes that whatever solved the JS challenge will continue to make subsequent requests. RQ4-S doesn't make that assumption, it correlates header consistency across requests directly, regardless of what the WAF blessed.
What this misses
A bot that maintains "browser-like" headers throughout the session passes RQ4-S. The detection requires an actual x in the RQ4 vector to fire. If the attacker invests in proper contextual headers, Sec-Fetch-Mode: cors on fetch-API POSTs, Sec-Fetch-Mode: navigate only on actual top-level GETs, proper Accept and Accept-Encoding per request type, there's no x to detect, no transition to flag.
This is the documented evasion ceiling. It's the same ceiling that any header-only detection scheme has. Closing it requires layers that operate outside the request headers entirely:
- JS challenge running on the bot's client. If the bot is curl_cffi or a non-Playwright tool, executing a JS challenge that proves "I am running a real JavaScript engine, in real time" is something the bot can't satisfy without burning the operational cost of running a real headless browser.
- Browser fingerprint snippet. Canvas variance, WebGL renderer matching the claimed GPU, audio context fingerprint, font enumeration, bots running on Linux containers or anti-detect browsers leave detectable artifacts here that proper bot-like headers can't compensate for.
- Behavioral analysis. Mouse movement, scroll cadence, typing rhythm, page-interaction timing across multiple requests. A bot that doesn't replay human-recorded behavioral data trips this even with perfect headers.
None of these are RQ4-S. They're additional layers that catch what RQ4-S can't.
Why RQ4-S is the most cost-effective layer to ship first
Among the bot-defense layers, header-based session-level correlation has the best cost-to-coverage ratio for a small team:
- No frontend instrumentation required. Unlike a browser fingerprint snippet, RQ4-S doesn't need to be embedded on the customer's pages.
- No JS execution required. Unlike a JS challenge, RQ4-S doesn't add a round trip or break embedded use cases (in-app webviews, server-side rendering).
- No proprietary database to maintain. Unlike TLS fingerprinting, the RQ4-S rules are derived from the public Fetch specification and don't drift as new client tools ship.
- Catches a specific high-value attack class (cookie handoff) that nothing else in the indie-tier product range catches structurally.
The cost is real but bounded: per-session state needs to be stored somewhere with a TTL matching the longest cookie lifetime you care about (~20 minutes for most WAF challenges). For Cloudflare Workers, KV is sufficient at this scale.
Limitations
- Cookie identification. RQ4-S keys on the session cookie. If the attacker can identify which cookie is being tracked and rotate it, session state doesn't accumulate. The spec recommends keying on a prioritized list of known session cookies (
cf_clearance,reese84,_abck, etc.) with a fallback to the entire cookie string. This catches common cookies; sophisticated attackers can defeat it by stripping cookies between requests (which then drops them out of the protected session and forces them through challenge again). - TTL. Session state expiry must match the cookie lifetime you're protecting. A bot can defeat RQ4-S by waiting longer than the TTL between the browser phase and bot phase, possible but slows the attack to a crawl.
- First-request bot. A bot that's a bot from request #1 doesn't trigger RQ4-S, there's no prior clean history. Per-request signals handle that case.
- Cross-request bot. A bot that maintains perfectly contextual headers throughout the session never produces an
xin any vector, so RQ4-S never fires. See above: this requires JS challenge or behavioral layers to close.
Common questions
Doesn't Cloudflare itself detect this?
Not on free or Pro plans. The JS challenge issues cf_clearance cookies; subsequent requests carrying a valid cf_clearance are trusted. Cloudflare's higher tiers (Bot Management on Business/Enterprise plans, $300+/month base, behavioral signals layered on top) may catch some handoff patterns via additional signals like client TLS fingerprint stability across the session. But for the millions of sites on free/Pro plans, the JS challenge is a one-shot gate, once passed, subsequent traffic isn't re-evaluated. The handoff attack exploits exactly that gap.
Why can't I just shorten the cookie expiration time?
You can, and it helps marginally, shortening cf_clearance from its 30-minute default to 5 minutes forces re-challenge more often. But (1) you can't change cf_clearance TTL on free/Pro Cloudflare plans (it's a CF-controlled cookie), and (2) even with a 1-minute window, the attacker can solve the challenge once in a Chromium and replay the cookie within that window to make hundreds of requests. Time-bounding cookies slows the attack but doesn't structurally prevent it. RQ4-S structurally prevents it because the detection is per-request, not per-session-window.
Does this work for non-Cloudflare challenges (Akamai, Imperva)?
Yes, the RQ4-S algorithm is challenge-vendor-independent. It correlates RQ4 vectors across requests sharing whichever session cookie the WAF is using. Plug in Akamai's _abck cookie, Imperva's reese84, F5 Shape's signed messages, the state machine is the same. The session-cookie identification step (which cookie do you track?) is the one part that varies per WAF; the rest of the logic is identical.
Is this what JA3 fingerprinting is supposed to solve?
No, different attack class. JA3 fingerprints the TLS handshake; useful for catching libraries that have distinctive TLS stacks (curl_cffi, tls-client, etc.). The cookie-reuse handoff attack uses a real browser to solve the challenge AND a real browser TLS stack on the replay (in our observed cases, the replayer often uses Playwright with stock Chrome TLS, which produces a clean JA3 hash). JA3 won't flag it. RQ4-S catches it because the bot's REQUEST DISPATCH LOGIC (which Sec-Fetch headers go on which method) differs from a real browser's, even when the TLS layer is identical.
Can I implement this without paying for an API?
Yes, the spec is open and the implementation is small (the reference simulator in our internal verification is about 50 lines of Python). The work involved: (1) extract a session ID from the request's cookie header, (2) maintain per-session state in a KV store or Redis (clean? count? flagged_at?), (3) compute the RQ4 vector for each request per the public spec, (4) apply the state machine. A weekend project for a competent engineer. The decision is whether you'd rather build and maintain it or pay for a managed implementation when one becomes available at this tier.
How long does the bot have to wait between solving the challenge and replaying to evade RQ4-S?
Longer than the session-state TTL. A typical RQ4-S implementation uses ~20 minutes of session state per cookie, long enough to cover the typical cookie lifetime, short enough to keep KV writes bounded. If the bot solves the challenge, waits 25 minutes, then replays, the session state has expired and the first replay request looks like a fresh session (count=1, no prior history to transition from). RQ4-S doesn't fire on that single request, but per-request signals still flag the curl_cffi headers if the bot uses curl_cffi for the replay. The economic effect: forcing a 20+ minute wait between solve and replay slows the attack tenfold and pushes the attacker toward either solving constantly (defeats the point of cookie sharing) or building Playwright-based replays (10x more expensive to operate at scale). Either way, the attack economics shift unfavorably for the attacker.
Related reading
- RQ4 specification and the reference implementation at github.com/rozetyp/rq4, the open standard, with Section 6 covering RQ4-S
- Detecting curl_cffi after TLS impersonation, complementary TLS-layer detection that catches commodity curl_cffi traffic regardless of session state
- Bot detection middleware for Next.js App Router, integration patterns
- Cloudflare Workers normalizes Accept-Encoding before your handler sees it, Workers runtime caveat that affects header-based detection signals
- Comparing fraud-check APIs, which other vendors ship session-level correlation