Sneaker Dev Logo
Back to Blog

Reverse Engineering Vercel's BotID

June 30, 2025

nullpt.rs

I’m conflicted every time I write one of these posts.
Reverse Engineering Vercel's BotIDThis 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

veritas profile picture

veritas

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}
15

That’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}
39

Discovering the c.js Script

Visiting this route in our browser, we can see that a new script is fetched: c.js

New script c.js present in the Network inspector

.

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}
22

Understanding 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}
8

2. 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);
18

3. 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().encode

The 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// ...
31

If 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}
38

Writing 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});
25

2. 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  });
21

3. 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});
16

Cleaning 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;
20

We 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});
19

This 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});
16

And 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});
17

With 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}
28

Understanding 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
36

Detect "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}
7

Collect 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}
23

Detect 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}
15

Store 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}
27

This 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}
13

Bypassing 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})();
49

Testing 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})();
26

Observations 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: true

I 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});
26
1is-bot: You are a human
2User Agent: HeadlessChrome
3Webdriver: true

We 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:

Requests for Kasada's anti-bot scripts

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


nullpt.rs

Blog: https://nullpt.rs

GitHub: https://github.com/nullpt-rs

Twitter: https://twitter.com/nullptrs