What a stack string contains
When a script creates an Error, the engine records the call stack at that moment and exposes it as a string in error.stack. The format is not standardised, and the engines differ:
- V8 (Chrome, Edge, Node): a first line
Error, then one line per frame,at functionName (url:line:column), orat url:line:columnfor anonymous code. Method frames are named by how they were called, such asObject.renderorHTMLDocument.onReady. The defaultError.stackTraceLimitis 10 frames. Columns point at the property name of a method call or the start of a plain call. URLs keep the query and drop the fragment. - JavaScriptCore (Safari): no header line, frames as
functionName@url:line:column, named by definition rather than call form, positions at the opening argument parenthesis, query and fragment stripped. In strict code, proper tail calls can remove a caller's frame. - SpiderMonkey (Firefox):
functionName@url:line:column, with eval andFunctionframes marked asline N > eval.
So a single stack string tells the engine, and the exact column numbers tell which engine's position rules produced it.
The render sample
A deployed challenge loader records a sample when it renders a widget: a method code, the elapsed milliseconds, and new Error().stack. The widget's iframe copies the sample into its first payload.
The sample shows who asked for the widget. When the widget is declared in the page's markup, the loader renders it from its own DOM-ready handler, and the stack contains only loader frames ending at the DOMContentLoaded handler. When a page script calls the render function, the stack contains that script's frames: its URL, line and column, and the names of its functions, up to the 10-frame limit in V8.
Because the sample is taken synchronously inside the render call, it shows whatever code was on the stack at that moment. Timers and event callbacks start with an empty stack: in our measurements a setTimeout callback's stack had exactly one frame, with no trace of who scheduled it.
What automation leaves in stacks
We measured new Error().stack in Chrome 156 for code run in different ways:
- Puppeteer
page.evaluate: frames atpptr:evaluate;%2Fpath%2Fto%2Fscript.js%3A20%3A33:1:8. Puppeteer appends a//# sourceURL=comment to evaluated code, and the URL encodes the driver script's absolute path, line and column on the controlling machine. - Playwright
page.evaluate:at eval (eval at evaluate (:370:30), <anonymous>:1:7),at UtilityScript.evaluate (<anonymous>:372:16)andat UtilityScript.<anonymous>, because Playwright runs user code through an evaluator class. - Raw protocol evaluation, init scripts and
evaluateOnNewDocument: frames likeat <anonymous>:1:35, with no URL at all. Real page scripts always have a URL, and eval'd page code showseval atwith its parent's URL.
These frames only reach the widget if the injected code is on the stack when the sample is taken. When automation calls the render function itself from an evaluated script, the render sample contains the page frames followed by the driver's frames. A widget rendered from markup by the loader shows only loader frames, whatever drives the browser.
The click stack
The same widget creates another error inside its checkbox click handler. That stack runs from the event listener through the widget's own virtual machine to the instruction that created the error, and it has to look like the claimed engine's: V8 names the listener frame HTMLInputElement.<anonymous>, JavaScriptCore leaves many frames anonymous and includes native Reflect.construct frames. A Safari User-Agent with a V8-shaped stack, or a click handler whose stack contains frames that are not the widget's, contradicts the session. See what a checkbox click records.
What follows for automation
Two practical points follow. First, let the page render its own widget: code injected through the automation protocol should not call into the page's widget API, because whatever is on the stack during that call is recorded. Second, the stack format is the engine's, so it can only be right if the engine is the one claimed; a User-Agent override cannot change how V8 prints a frame. The engine-identification side of this is covered in identifying the engine behind the user agent, and the protocol side in what is CDP detection.
The through-line
A stack trace is the one value in the browser that records how the current code was reached. Most fingerprinting asks what the browser is; the stack asks how it got here, and an automation layer that sits on the call path shows up in the answer. The same idea, observing the observer, is behind the console getter described in timing native functions.
