Reverse Engineering Vercel's BotID
June 30, 2025
• nullpt.rs
I’m conflicted every time I write one of these posts.
This article may have been republished from another source and might not have been originally written for this site.Originally published at https://nullpt.rs/reversing-botid

Preamble
I’m conflicted every time I write one of these posts.
On one hand, anti-bots are important. They help stop credential stuffing attacks 1, block denial-of-service attempts, and keep bad faith scrapers from inflating hosting costs
(especially in the era of AI). If you’re running a business on the internet these days, it’s hard to survive without
2somekind of bot protection in place.
On the other hand, I kind of hate them.
Most anti-bots rely on aggressive browser fingerprinting 3, collecting a plethora of device signals
, and quietly deciding whether you're “human enough”. They often break for those using Linux, smaller browsers like Ladybird
4, or even privacy-focused ones like Brave. They push the web toward a monoculture where only certain OS/browser combos are considered legitimate.
5So I sit in this weird middle ground. I get why they exist. I get why companies use them. But I also worry about what they're doing to the open web.
This blog post doesn’t present anything particularly groundbreaking. In fact, much of it revisits topics covered before on this site. My goal here is simply to invite discussion and hear different perspectives on the matter.
Introduction
Vercel recently announced BotID, "an invisible CAPTCHA that protects against sophisticated bots without showing visible challenges or requiring manual intervention". It’s available in two modes: Basic
and Deep Analysis
. The Basic tier is free for all users. Deep Analysis costs $1 per 1,000 requests. Both modes rely on client-side signals to detect bots, however, Deep Analysis is powered by Kasada's anti-bot script and is meant to catch more sophisticated bots.
This post primarily focuses on Basic mode, however, Deep Analysis leverages scripts we've covered in a previous series.
Setup for both is pretty straightforward.
Setting up BotID
You can start by adding BotID to your existing Next.js project like this:
yarn add botid
Then, protect a route using the following code:
1import { checkBotId } from 'botid/server';
2import { NextRequest, NextResponse } from 'next/server';
3
4export async function POST(request: NextRequest) {
5 const verification = await checkBotId();
6
7 if (verification.isBot) {
8 return NextResponse.json({ error: 'Access denied' }, { status: 403 });
9 }
10
11 const data = await processUserRequest();
12
13 return NextResponse.json({ data });
14}
15That’s the basics of BotID setup. You can then create a simple form page that calls this API:
1"use client";
2import { FormEvent, useState } from 'react'
3
4export default function Page() {
5 const [bot, setBot] = useState<string | undefined>(undefined);
6
7 async function onSubmit(event: FormEvent<HTMLFormElement>) {
8 event.preventDefault()
9
10 const formData = new FormData(event.currentTarget)
11 const response = await fetch('/api/sensitive', {
12 method: 'POST',
13 body: formData,
14 })
15
16 const data = await response.json<{
17 error?: string;
18 data?: string;
19 }>()
20
21 if (data.error) {
22 setBot("BOT");
23 } else if (data.data) {
24 setBot("HUMAN");
25 }
26 }
27
28 return (
29 <div>
30 {bot === "HUMAN" && <p id="is-bot">You are a human</p>}
31 {bot === "BOT" && <p id="is-bot">You are a bot</p>}
32 <form onSubmit={onSubmit}>
33 <input type="text" name="whatever" />
34 <button type="submit">Submit</button>
35 </form>
36 </div>
37 )
38}
39Discovering the c.js Script
Visiting this route in our browser, we can see that a new script is fetched: c.js

.
A preview of the script shows reveals obfuscated JavaScript. This is clear from the use of random-looking hex offsets and indirect calls to functions in globals like crypto
and JSON
, where a separate function is used to resolve the actual method names.
1async function X(b, x) {
2 const S = k;
3 // It's calling stuff from the crypto API but the function names are mangled
4 let L = crypto[S(0x271, 'x2Nz') + S(0x248, 's74E') + S(0x1a3, 'sKIN') + S(0x1cc, 'rKTC') + 'ues'](new Uint8Array(0x71 * 0x1f + -0xca4 + 0x1 * -0xfb))
5 , V = crypto[S(0x255, '(mPC') + S(0x27b, 'mz*i') + S(0x230, 'Qq38') + S(0x215, 'rTDp') + S(0x20f, '[]ns')](new Uint8Array(-0x2144 + 0x33 * -0xad + 0x43c7 * 0x1))
6 , W = await crypto[S(0x23e, '#J9v') + S(0x1c0, 'KL$e')][S(0x223, 'UnKT') + S(0x1ce, 'KL$e') + S(0x1a4, 's74E')](H[S(0x240, 'BxPe') + 'Yk'], new TextEncoder()[S(0x263, 'ZsXG') + 'ode'](b), H[S(0x205, '0dQT') + 'gg'], !(-0x1543 * 0x1 + 0xd1 * -0x1d + -0xf * -0x2ff), [H[S(0x1b9, 'BxPe') + 'VA'], S(0x20a, 'S#TT') + S(0x26b, 'BxPe') + 'Key'])
7 , E = await crypto[S(0x23d, '7#V2') + S(0x1f8, 'uGPe')][S(0x222, 'hyOI') + S(0x22b, 'v4[O') + 'Key']({
8 'name': H[S(0x286, '@UgF') + 'gg'],
9 'salt': L,
10 'iterations': 0x186a0,
11 'hash': H['CPO' + 'bW']
12 }, W, {
13 'name': 'AES' + '-GC' + 'M',
14 'length': 0x100
15 }, !(0x5a9 + 0x13a * 0x18 + 0x2318 * -0x1), [S(0x284, 'uGPe') + 'ryp' + 't'])
16 , R = await crypto[S(0x261, 'ggq(') + S(0x26f, 'UnKT')]['enc' + S(0x24d, 'rTDp') + 't']({
17 'name': S(0x226, 'S#TT') + '-GC' + 'M',
18 'iv': V
19 }, E, new TextEncoder()['enc' + S(0x1c1, '@UgF')](JSON[S(0x1fa, 'ZsXG') + S(0x227, 'j]8P') + S(0x22a, '6nM!')](x)));
20 return btoa(String[S(0x1bc, 'bQHG') + S(0x262, 'iIeX') + S(0x253, 'KL$e') + 'ode'](...L, ...V, ...new Uint8Array(R)));
21}
22Understanding the Obfuscation
Using Babel, we can traverse all call expressions to the string decoding functions and replace them with the resulting cleartext to return the original function names. This makes static analysis a lot easier.
The script’s string decoding process works in three steps:
1. Define the encoded string constants
These are stored in a function that returns an array of all the encoded strings used throughout the script.
1function w() {
2 const D = ['veeq', 'WOfpW70', '... the rest of the strings ...'];
3 w = function () {
4 return D;
5 };
6 return w();
7}
82. Shuffle the string constants
Before we can decode the string constants, the array is shuffled until a specific predicate, determined at build time, returns true. This shuffling happens inside an IIFE and looks like this:
1(function (H, P) {
2 const I = J,
3 r = H();
4 while (!![]) {
5 try {
6 const q = parseInt(I(0x1c4, 'ZGpA')) / (0x537 * 0x5 + 0xbf + -0x1ad1) + parseInt(I(0x295, 'Lj3S')) / (-0x981 + -0x1084 + 0x1a07) + parseInt(I(0x209, 'MxSD')) / (-0x391 * -0x7 + -0x1cd1 + -0x17 * -0x2b) * (parseInt(I(0x27b, 'nF93')) / (-0x26f0 * -0x1 + -0x105e + -0x2 * 0xb47)) + -parseInt(I(0x1c2, 'Lj3S')) / (-0x36 * -0xb7 + -0x1 * -0xb89 + -0x190f * 0x2) * (-parseInt(I(0x26d, '3bXU')) / (0x743 * 0x3 + 0x1a07 + -0x2fca)) + -parseInt(I(0x1cd, '3bXU')) / (-0x17aa + -0xe90 + 0x2641) * (-parseInt(I(0x259, '6TSl')) / (-0x1ead * -0x1 + -0x123 * -0x12 + -0x331b)) + parseInt(I(0x1fc, 'dP9k')) / (-0x5 * -0x63d + -0xdea * 0x1 + 0x2 * -0x89f) + -parseInt(I(0x28e, 'Ynb)')) / (-0x15d1 + 0x1 * -0x1795 + -0x2d70 * -0x1) * (parseInt(I(0x1b6, '!CQn')) / (-0x520 * -0x1 + 0x8ef * -0x2 + -0x1 * -0xcc9));
7 if (q === P) {
8 break;
9 }
10 else
11 r['push'](r['shift']());
12
13 } catch (d) {
14 r['push'](r['shift']());
15 }
16 }
17})(w, -0x8aa * 0x95 + -0xf2 * 0x159 + -0x32 * -0x2f2b);
183. Define the decode function
This function is called throughout the entire script. It retrieves the cleartext strings from the shuffled array and is often used when accessing or invoke functions on global objects:
e.g.:
1new TextEncoder()['enc' + J(0x1c1, '@UgF')] // Likely is a call to TextEncoder().encodeThe function responsible is an obfuscated mess:
1function J(U, O) {
2const g = w();
3return J = function(y, H) {
4 y = y - (0x244f + 0xd7 * -0x21 + -0x6b3);
5 let P = g[y];
6 if (J['WizbPL'] === undefined) {
7 var r = function(i) {
8 const G = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';
9 let C = ''
10 , Y = '', X = C + r;
11 for (let m = 0x1 * -0x161b + -0x1223 * 0x1 + 0x283e * 0x1, j, s, b = -0x368 * -0x7 + -0xcc + -0x170c; s = i['charAt'](b++); ~s && (j = m % (0x33b * 0x2 + 0x21ae * 0x1 + -0x2820) ? j * (-0xb5 * -0x6 + 0x19d * 0x1 + 0x23 * -0x29) + s : s,
12 m++ % (-0x1de2 * 0x1 + -0x229d + -0x1581 * -0x3)) ? C += X['charCodeAt'](b + (-0x1039 + -0x20b * -0x4 + 0x1 * 0x817)) - (0x1 * 0x15bf + -0x25f0 + -0x5 * -0x33f) !== 0x2 * 0x301 + 0x1e73 + 0x3d * -0x99 ? String['fromCharCode'](0xc38 + -0x1 * -0x1e32 + 0x17 * -0x1cd & j >> (-(0x1 * -0x8d7 + 0x2b * -0x31 + 0x1114 * 0x1) * m & -0x16f6 + 0x2 * -0x179 + -0xcf7 * -0x2)) : m : 0x9 * 0xb1 + 0x712 + -0xd4b) {
13 s = G['indexOf'](s);
14 }
15 for (let x = 0xc7 * -0x1f + 0x28 + 0x17f1, c = C['length']; x < c; x++) {
16 Y += '%' + ('00' + C['charCodeAt'](x)['toString'](-0x1c20 + -0x8f3 + 0x2523))['slice'](-(-0x1e8b + 0x12d * -0xf + 0x202 * 0x18));
17 }
18 return decodeURIComponent(Y);
19 };
20 const F = function(G, C) {
21 let Y = [], X = 0x240d + 0x10b2 + -0x34bf, m, b = '';
22 G = r(G);
23 let c;
24 for (c = -0x884 + -0xc47 * -0x1 + 0x3c3 * -0x1; c < -0x224e + -0x1f32 + 0x1 * 0x4280; c++) {
25 Y[c] = c;
26 }
27 for (c = -0x35d + 0x1202 + -0xea5 * 0x1; c < -0x2326 + 0x3f * -0x97 + 0x494f; c++) {
28 X = (X + Y[c] + C['charCodeAt'](c % C['length'])) % (-0xba5 + -0xf50 + -0x11 * -0x1a5),
29 m = Y[c],
30// ...
31If you're familiar with JavaScript obfuscation, or are a regular reader of our blog, you might recognize the style of this obfuscation. It's javascript-obfuscator, an extremely popular, open-source JavaScript obfuscator. If you’ve ever encountered obfuscated JS in the wild, there’s a good chance it was produced by this tool or a modified variant.
Because of its popularity, there are already public tools that can reverse the transformations. The most reliable is a deobfuscator by ben-sb. You can try it out at https://obf-io.deobfuscate.io.
In this article, however, we’ll create our own deobfuscator from scratch to understand how the process works under the hood.
By looking at the obfuscator’s source, we can find the template for the string decode function. We see that the function is just doing a Base64 decode (atob) followed by RC4 (combined with some anti-tamper) to reveal the original string:
1export function StringArrayRC4DecodeTemplate (
2 randomGenerator: IRandomGenerator
3): string {
4 const identifierLength: number = 6;
5 const initializedIdentifier: string = randomGenerator.getRandomString(identifierLength);
6 const rc4Identifier: string = randomGenerator.getRandomString(identifierLength);
7 const onceIdentifier: string = randomGenerator.getRandomString(identifierLength);
8
9 return `
10 if ({stringArrayCallsWrapperName}.${initializedIdentifier} === undefined) {
11 {atobPolyfill}
12 {rc4Polyfill}
13 {stringArrayCallsWrapperName}.${rc4Identifier} = {rc4FunctionName};
14
15 {stringArrayCacheName} = arguments;
16
17 {stringArrayCallsWrapperName}.${initializedIdentifier} = true;
18 }
19
20 const firstValue = stringArray[0];
21 const cacheKey = index + firstValue;
22 const cachedValue = {stringArrayCacheName}[cacheKey];
23
24 if (!cachedValue) {
25 if ({stringArrayCallsWrapperName}.${onceIdentifier} === undefined) {
26 {selfDefendingCode}
27
28 {stringArrayCallsWrapperName}.${onceIdentifier} = true;
29 }
30
31 value = {stringArrayCallsWrapperName}.${rc4Identifier}(value, key);
32 {stringArrayCacheName}[cacheKey] = value;
33 } else {
34 value = cachedValue;
35 }
36 `;
37}
38Writing the Tool
While this information is cool to know, for our case it doesn't really matter what encoding scheme they're using since we won't be reimplementing it. Instead, we'll use Babel to extract all the functions needed for string decoding and call them whenever we run into a decode call in the source.
Following the steps described above:
1. Finding the encoded string constants
Using @babel/traverse
, we can look for a function that defines an array made up entirely of string constants. That signature is good enough for our purposes since there's only one function like that in the script:
1const ast = parser.parse(source, {
2 sourceType: 'module',
3 plugins: ['dynamicImport']
4});
5
6let stringConstantsFunction = null;
7
8traverse.default(ast, {
9 // Visit all function declarations
10 FunctionDeclaration(path) {
11 const body = path.node.body.body;
12 // Get the first statement of the function declaration
13 const [firstStatement] = body;
14 if (
15 // If we are declaring a variable that is an array with only string literals
16 firstStatement?.type === 'VariableDeclaration' &&
17 firstStatement?.declarations[0]?.init?.type === 'ArrayExpression' &&
18 firstStatement?.declarations[0]?.init?.elements?.every(el => el?.type === 'StringLiteral')
19 ) {
20 // We found the string constants function
21 stringConstantsFunction = path.node;
22 }
23 }
24});
252. Finding the shuffle function
For this, we can search for an IIFE that contains a while(!![])
loop:
1traverse.default(ast, {
2 ExpressionStatement(path) {
3 const expr = path.node.expression;
4 if (
5 expr?.type === 'CallExpression' &&
6 expr.callee.type === 'FunctionExpression'
7 ) {
8 // Look for a while statement with `!!` in the tested expression
9 const hasShuffle = expr.callee.body.body.some(n =>
10 n.type === 'WhileStatement' &&
11 n.test.type === 'UnaryExpression' &&
12 n.test.operator === '!' &&
13 n.test.argument.type === 'UnaryExpression' &&
14 n.test.argument.operator === '!'
15 );
16 if (hasShuffle)
17 shuffleNode = path.node;
18 }
19 }
20 });
213. Finding the decode function
Now, we just need to look for a function where the first statement includes a call to the string constants function from step 1:
1traverse.default(ast, {
2FunctionDeclaration(path) {
3 const body = path.node.body.body;
4 const [firstStatement] = body;
5 if (
6 // Are we calling the string constants function?
7 firstStatement?.type === 'VariableDeclaration' &&
8 firstStatement?.declarations[0].init?.type === 'CallExpression' &&
9 firstStatement?.declarations[0].init.callee.name === stringConstantsFunction.id.name
10 ) {
11 // We found the string decode function
12 stringDecodeFunction = path.node;
13 }
14},
15});
16Cleaning the script
Using the Node vm
package, we can move these functions into a self-contained script and run them in their own execution environment.
It’s worth noting that this is not a sandboxed environment so this doesn’t offer any real security or isolation:
1// Create empty AST as the base for our new script
2const deobfAst = parser.parse('', { sourceType: 'module' });
3
4// Push the functions we located
5deobfAst.program.body.push(stringConstantsFunction, stringDecodeFunction, shuffleNode);
6// Export it so we can use it
7deobfAst.program.body.push(
8 parser.parse(`module.exports = ${stringDecodeFunction.id.name};`, { sourceType: 'module' }).program.body[0]
9);
10
11const { code: deobfCode } = generate.default(deobfAst, { compact: true });
12const ctx = { module: {} };
13
14// Create a VM context to run in
15vm.createContext(ctx);
16vm.runInContext(deobfCode, ctx);
17
18// Reference the exported function so we can call it in our next pass
19const decodeString = ctx.module.exports;
20We can now go over all the encoded strings and call the deobfuscate function on each one, replacing the original node with its return value:
1const deobfuscateStrings = ({ types: t }) => ({
2 visitor: {
3 CallExpression(path) {
4 const [first, second] = path.node.arguments;
5 if (
6 // Is the first argument a numeric hex literal (e.g.: 0x123)
7 t.isNumericLiteral(first) &&
8 first.extra?.raw?.startsWith('0x') &&
9 // Is the second argument a string literal?
10 t.isStringLiteral(second)
11 ) {
12 // Call the decode function and replace the node with the return value
13 const val = decodeString(first.value, second.value);
14 path.replaceWith(t.stringLiteral(val));
15 }
16 }
17 }
18});
19This will correctly convert our previously ciphered function call from
1new TextEncoder()['enc' + S(0x1c1, '@UgF')]to
1new TextEncoder()["enc" + "ode"]As an added convenience, we can add a constant folding pass to turn expressions like "enc" + "ode"
into just encode
:
1const constantFoldPlugin = ({ types: t }) => ({
2 visitor: {
3 BinaryExpression: {
4 exit(path) {
5 if (
6 path.node.operator === '+' &&
7 t.isStringLiteral(path.node.left) &&
8 t.isStringLiteral(path.node.right)
9 ) {
10 path.replaceWith(t.stringLiteral(path.node.left.value + path.node.right.value));
11 }
12 }
13 }
14 }
15});
16And similarly for binary expressions that only contain numeric literals (e.g. -0x239e + -0xa7 * -0xf + 0x19d6
becomes 1
):
1const evaluateConstants = ({ types: t }) => ({
2 visitor: {
3 Expression(path) {
4 // Avoid replacing expressions that have already been simplified
5 if (path.node._evaluated) return;
6
7 const evaluation = path.evaluate();
8
9 if (evaluation.confident && typeof evaluation.value === 'number') {
10 const literal = t.numericLiteral(evaluation.value);
11 literal._evaluated = true; // prevent re-visiting
12 path.replaceWith(literal);
13 }
14 }
15 }
16});
17With these passes, the previously obfuscated X
function becomes a lot clearer:
1async function X(b, x) {
2 const S = k;
3 let L = crypto["getRandomValues"](new Uint8Array(16)),
4 V = crypto["getRandomValues"](new Uint8Array(12)),
5 // These variables are set in the beginning of the script
6 // but i've included them here as comments to make the
7 // function's purpose more obvious
8
9 W = await crypto["subtle"]["importKey"](H["nOdPK"], new TextEncoder()["encode"](b), H["QTrOm"] /* PBKDF2 */, !1, [H["vzGnW"] /* deriveBits */, H["EeBaB"] /* deriveKey */]),
10 E = await crypto["subtle"]["deriveKey"]({
11 'name': H["QTrOm"] /* PBKDF2 */,
12 'salt': L,
13 'iterations': 100000,
14 'hash': H["TtSII"] /* SHA-256 */
15 }, W, {
16 'name': H["mgAqr"] /* AES-GCM */,
17 'length': 256
18 }, !1, ["encrypt"]),
19 R = await crypto["subtle"]["encrypt"]({
20 'name': H["mgAqr"] /* AES-GCM */,
21 'iv': V
22 }, E, new TextEncoder()["encode"](JSON["stringify"](x)));
23 // 'XyyaZ': function(s, b) {
24 // return s(b);
25 // },
26 return H["XyyaZ"](btoa, String["fromCharCode"](...L, ...V, ...new Uint8Array(R)));
27}
28Understanding the X Function
This function uses the WebCrypto API to derive an AES-256 key via PBKDF2, encrypts the data using AES-GCM, and encodes the salt, IV, and payload into a Base64 string. This is what gets sent to the backend as part of the browser signal verification process.
The Browser Signal Payload
The browser signals themselves are defined in several functions:
Detect Leaks in window & navigator
Many automation frameworks leak properties into the window
object, either for convenience or to enable IPC during development. While useful for debugging, these properties can become an easy giveaway and a reliable way to flag yourself as a bot.
1// Detect various web driver leaks
2function v(s /* s = window */) {
3 const o = k;
4 // These two properties are set when controlling
5 // a Chromium based browser through the Chrome Devtools Protocol (CDP)
6 return !!s["domAutomation"] ||
7 !!s["domAutomationController"] ||
8 // Selenium detection
9 !!s["_WEBDRIVER_ELEM_CACHE"] ||
10 // PhantomJS detection (https://phantomjs.org/)
11 !!s["callPhantom"] ||
12 !!s["_phantom"] ||
13 // NightmareJS detection (https://github.com/segment-boneyard/nightmare)
14 !!s["__nightmare"] ||
15 // chromedriver detection (https://chromium.googlesource.com/chromium/src/+/lkgr/chrome/test/chromedriver/chrome/devtools_client_impl.cc)
16 !!s["cdc_adoQpoasnfa76pfcZLmcfl_Array"] ||
17 !!s["cdc_adoQpoasnfa76pfcZLmcfl_Promise"] ||
18 !!s["cdc_adoQpoasnfa76pfcZLmcfl_Symbol"] ||
19 // Puppeteer detection
20 Function["prototype"]["toString"]["toString"]()["includes"](H["NeMls"] /* __puppeteer_evaluation_script__ */) ||
21 Function["prototype"]["toString"]["toString"]()["includes"](H["WKkio"] /* __playwright_evaluation_script__ */);
22}
23
24function F(s /* s = window */) {
25 const B = k;
26 // Selenium check
27 return q = "bmB3TrbX", !!s["navigator"]["seleniumWebdriver"] ||
28 // These property is set to true when controlling Chrome using CDP
29 !!s["navigator"]["webdriver"] ||
30 !!s["webdriver"] ||
31 !!s["driver"] ||
32 // Another selenium check
33 !!s["selenium"];
34}
35
36Detect "headless" in User Agent
1function i(s /* s = window */) {
2 const M = k;
3 // Check if the navigator's user agent includes "headless".
4 // This is true when Chromium is being controlled with the `--headless` flag passed.
5 return s["navigator"]["userAgent"]["toLowerCase"]()["includes"](H["ZUIun"] /* headless */);
6}
7Collect Browser's GPU Renderer & Vendor Information
By creating a canvas
and accessing the WebGL context, the script can pull details about the GPU vendor and renderer. This is helpful for detecting headless environments, where the renderer is often something like “SwiftShader”, Google’s CPU-based fallback for graphics 6.
Because this info can be easily spoofed, more advanced anti-bots go a step further. They’ll actually draw something to the canvas, hash the result, and compare that hash to known good or bad values. This allows them to detect subtle differences in how GPUs render images, which can be harder to fake.
Vercel’s Basic Mode doesn’t do any image hashing yet, but it’s a logical next step if they decide to improve their WebGL detection.
If you’re curious about how this works at scale, check out Google’s paper on Picasso: Lightweight Device Class Fingerprinting for Web Clients7
1function G(s) {
2 const T = k;
3 // Create a canvas element
4 let b = s["document"]["createElement"]("canvas");
5 b["getContext"]("webgl") || b["getContext"](H["aSVub"] /* experimental-webgl */);
6
7 // Get the WebGL context
8 let x = b["getContext"](H["LauIM"] /* webgl */) || b["getContext"]("experimental-webgl"),
9 c = x?.["getExtension"]?.(H["TCEXm"] /* WEBGL_debug_renderer_info */);
10
11 // Return the vendor and renderer
12 // On my desktop machine, this returns
13 // Vendor: Google Inc. (NVIDIA)
14 // Renderer: ANGLE (NVIDIA, NVIDIA GeForce RTX 4080 (0x00002704) Direct3D11 vs_5_0 ps_5_0, D3D11)
15
16 // This is used to check if the browser is utilizing a GPU for rendering
17 // Or is relying on software rendering (useful for checking if browser is headless or running in a VM without a GPU)
18 return c ? (r = H["vtKat"], {
19 'v': x["getParameter"](c["UNMASKED_VENDOR_WEBGL"]),
20 'r': x["getParameter"](c["UNMASKED_RENDERER_WEBGL"])
21 }) : null;
22}
23Detect Chrome Devtools Protocol (CDP)
The function attempts to detect whether a Chromium-based browser is being instrumented using the Chrome DevTools Protocol (CDP). It creates an Error object and overrides its stack property with a custom getter. Under normal circumstances, Chromium doesn’t access the stack property when console functions are called. However, if CDP is enabled, and has issued the Runtime.enable
command, Chromium serializes the object to send it over the DevTools protocol. This serialization process accesses the stack property, triggering the custom getter.
If this was confusing, please read Antoine Vastel's How New Headless Chrome & the CDP Signal Are Impacting Bot Detection 8 for a detailed explanation :)
This check also doubles as a way to see if the user has the devtools opened.
1function C() {
2 const A = k;
3 // If we are running this script locally, don't run this check
4 if (location["hostname"] === H["GftGm"] /* localhost */ || location["search"]["includes"](H["IMeIJ"] /* botDebug */)) return !1;
5
6 // CDP check
7 let s = !1 // false,
8 b = new Error('');
9 return Object["defineProperty"](b, H["IPbzr"], {
10 'get'() {
11 return s = !0 /* true */, '';
12 }
13 }), console['log'](b), s;
14}
15Store Entire Payload in an Object
The results of all of these detections are then stored in an object returned by function Y
:
1function Y() {
2 // This function runs and returns the results of
3 // all of the browsdr checks
4 const a = k;
5 let s = window;
6 return {
7 // Any web driver leaks in navigator / window?
8 'p': H["YvjhB"](v, s),
9 /*
10 'nszIW': function(s, b) {
11 return s * b;
12 },
13
14 Multiply these two constants.
15 */
16 'S': H["nszIW"](0.4856383631795842, 2),
17 // WebGL renderer and vendor object
18 'w': H["YvjhB"](G, s),
19 // More selenium, webdriver, phantom checks in globals
20 's': F(s),
21 // Does user agent contain "headless"?
22 'h': H["YvjhB"](i, s),
23 // Another CDP / Devtools check by abusing the `Error` object
24 'b': H["uUxBo"](C)
25 };
26}
27This payload is then passed to the encryption function described earlier.
Next.js hooks into fetch
calls to protected routes and passes the encrypted result as the x-is-human
header.
1window.fetch = async (e, t) => {
2 // ...
3 let p = await c()
4 , m = new Headers((null == t ? void 0 : t.headers) || (e instanceof Request ? e.headers : void 0));
5 m.set("x-is-human", JSON.stringify(p));
6 let g = {
7 ...t,
8 headers: m
9 };
10 // ...
11 return window.fetch(e, g)
12}
13Bypassing the Checks
These are all the checks in place at the time of writing. Vercel's BotID Basic mode is, as the name suggests, pretty basic. These checks can be easily bypassed by spoofing the values before they’re collected:
1// Call Runtime.disable to prevent CDP check from being flagged
2(function patchDetection() {
3 // Patch navigator properties
4 Object.defineProperty(navigator, 'webdriver', { get: () => false });
5
6 delete navigator.seleniumWebdriver;
7 delete navigator.driver;
8 delete navigator.selenium;
9
10 // Patch global window leaks
11 delete window.domAutomation;
12 delete window.domAutomationController;
13 delete window._WEBDRIVER_ELEM_CACHE;
14 delete window.callPhantom;
15 delete window._phantom;
16 delete window.__nightmare;
17 delete window.cdc_adoQpoasnfa76pfcZLmcfl_Array;
18 delete window.cdc_adoQpoasnfa76pfcZLmcfl_Promise;
19 delete window.cdc_adoQpoasnfa76pfcZLmcfl_Symbol;
20
21 // Override Function.prototype.toString to hide Puppeteer/Playwright markers
22 const originalToString = Function.prototype.toString;
23 Function.prototype.toString = function () {
24 const result = originalToString.call(this);
25 return result
26 .replace(/__puppeteer_evaluation_script__/g, '')
27 .replace(/__playwright_evaluation_script__/g, '');
28 };
29
30 // Patch navigator.userAgent to hide "Headless"
31 const originalUserAgent = navigator.userAgent;
32 Object.defineProperty(navigator, 'userAgent', {
33 get: () => originalUserAgent.replace(/HeadlessChrome|headless/gi, 'Chrome')
34 });
35
36 // Patch WebGLRenderingContext.getParameter to spoof renderer & vendor
37 const getParameter = WebGLRenderingContext.prototype.getParameter;
38 WebGLRenderingContext.prototype.getParameter = function (param) {
39 const ext = this.getExtension('WEBGL_debug_renderer_info');
40 if (param === ext.UNMASKED_VENDOR_WEBGL) {
41 return "Google Inc. (NVIDIA)";
42 }
43 if (param === ext.UNMASKED_RENDERER_WEBGL) {
44 return "ANGLE (NVIDIA, NVIDIA GeForce RTX 4080 (0x00002704) Direct3D11 vs_5_0 ps_5_0, D3D11)";
45 }
46 return getParameter.call(this, param);
47 };
48})();
49Testing Detection with Playwright
I wrote a Playwright script to test two things:
Does Basic BotID detect an unpatched Playwright?
If so, can I bypass BotID with my patches?
While testing the first part, I noticed something weird.
1const { chromium } = require('playwright');
2
3(async () => {
4 const browser = await chromium.launch({ headless: true });
5 const page = await browser.newPage();
6
7 // Go to the page
8 await page.goto('https://blog-git-botid-veritasjs-projects.vercel.app/test');
9
10 // Submit the form
11 await Promise.all([
12 page.locator('button').evaluate(button => button.click())
13 ]);
14
15 const result = await page.locator('p').innerText();
16 console.log(`Is Bot: ${result}`);
17
18 const userAgent = await page.evaluate(() => navigator.userAgent);
19 console.log(`User Agent: ${userAgent}`);
20
21 const webdriver = await page.evaluate(() => navigator.webdriver);
22 console.log(`Webdriver: ${webdriver}`);
23
24 await browser.close();
25})();
26Observations from Playwright Testing
Output:
1Is Bot: You are a human
2User Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/138.0.7204.23 Safari/537.36
3Webdriver: trueI found this odd, since our user agent contains Headless
and navigator.webdriver
is set to true
. That should be enough to trip at least two flags, making it obvious to BotID that we’re a bot.
To dig deeper, I wrote some patches to make sure we fail every check:
1await page.addInitScript(() => {
2 // We are using a webdriver
3 window.domAutomation = true;
4 window.navigator.webdriver = true;
5
6 // Change the user agent to include Headless
7 Object.defineProperty(navigator, 'userAgent', {
8 get: () => 'HeadlessChrome',
9 });
10
11 // Spoof WebGL attributes to tell them we are software rendering
12 const getParameter = WebGLRenderingContext.prototype.getParameter;
13 WebGLRenderingContext.prototype.getParameter = function (param) {
14 const ext = this.getExtension('WEBGL_debug_renderer_info');
15 if (ext) {
16 if (param === ext.UNMASKED_VENDOR_WEBGL) {
17 return "LOL obviously fake vendor!";
18 }
19 if (param === ext.UNMASKED_RENDERER_WEBGL) {
20 return "Google SwiftShader";
21 }
22 }
23 return getParameter.call(this, param);
24 };
25});
261is-bot: You are a human
2User Agent: HeadlessChrome
3Webdriver: trueWe were still wrongly identified as a human. My guess is that Vercel is using this launch window to gather signals from a variety of devices before they start enforcing blocks. This likely helps them avoid false positives while they figure out which values to label as “bot-like” versus “human.”
Out of curiosity, I replaced the entire signals payload with an empty object {}
in DevTools and only then was I flagged as a bot.
At the moment, it seems Basic mode is so basic that it allows everything to pass as human. That’ll likely change as they gather more telemetry to better identify what a bot signal looks like.
Deep Analysis Mode
Vercel recommends using their Deep Analysis
mode instead of Basic
. This mode uses Kasada's anti-bot to collect even more signals and compare them against their existing trained model to detect bot-like requests.
Enabling this mode requires a Pro
($20/mo) or Enterprise
plan, plus an additional cost of $1/1000 req
. I upgraded my project from Hobby
to Pro
to see how Deep Analysis mode works.
With one toggle in the dashboard, Deep Analysis mode was live. I immediately noticed some new but familiar Kasada network requests:

How Deep Analysis Works
Deep Analysis uses Kasada's anti-bot script, the same scripts deployed at sites like Nike. It uses a custom virtual machine to obfuscate their signal collection. This means we'd need to disassemble the embedded bytecode to understand what is being sent to their servers for analysis.
This is a much larger undertaking, and was actually done in another nullpt.rs series titled Devirtualizing Nike.com's Bot Protection by author umasi a while back.
Check it out if you're curious :)
Conclusion
In its current form, Vercel’s BotID (Basic mode) doesn’t seem configured to actually stop bots. It’s likely being used to quietly collect signals for its models before stricter detection kicks in. Deep Analysis, on the other hand, does actively block more advanced bots using Kasada’s fingerprinting scripts and classifiers.
Both modes rely on fingerprinting and client-side data collection, reinforcing the broader trend: anti-bots are increasingly opaque, data-hungry, and can skew toward a narrow definition of what a “normal” user is.
These scripts serve a real purpose, but the way they’re built continues to shape the web in ways that are hard to ignore and even harder to challenge.
Curious to hear your thoughts
Footnotes
- https://blog.castle.io/credential-stuffing-attacks-anatomy-detection-and-defense/#what-is-a-credential-stuffing-attack↩ - https://research.google/pubs/picasso-lightweight-device-class-fingerprinting-for-web-clients/↩ - https://datadome.co/threat-research/how-new-headless-chrome-the-cdp-signal-are-impacting-bot-detection/↩
https://twitter.com/blastbotsfedi:
https://infosec.exchange/@voidstarbluesky:
https://bsky.app/profile/1999.nycdiscord: nullptrs