A bot lands on your login page claiming to be a Windows desktop on an Intel chip. Its User-Agent says so, its navigator.platform says so, and its screen size and timezone all agree. One WebAssembly instruction says it's running on ARM. That contradiction is the whole story here: WebAssembly's relaxed SIMD extension leaks the CPU architecture the browser is actually running on, in a way that's hard to fake and easy to check. This is a teardown of how that leak works, what it can and can't tell a fraud team, and where it quietly falls apart.

What Is CPU Fingerprinting via WebAssembly SIMD?

CPU fingerprinting via WebAssembly SIMD is the practice of inferring a device's processor architecture or model from how the browser's Wasm engine executes SIMD instructions. It works two ways: reading the result of nondeterministic relaxed-SIMD ops, which differs by architecture, and timing SIMD-heavy loops, which differs by microarchitecture. The result-based signal is the more reliable starting point.

The signal isn't the CPU — it's how the engine lowers one instruction on that hardware

Start with the thing that trips people up.

You are not reading a serial number off the silicon. WebAssembly runs in a sandbox with no CPUID, no model register, no direct hardware query. <cite index="26-1">The sandboxed environment in browsers prevents</cite> the native inspection an attacker with local code would enjoy.

What you can read is behavior. Specifically, the behavior the browser's just-in-time compiler chose when it lowered a Wasm instruction to real machine code on this particular CPU.

That distinction runs through everything below. The observable is always engine × architecture, never the CPU alone.

Here's why the surface exists at all.

Fixed-width Wasm SIMD, the 128-bit v128 type, was designed to be deterministic. Same inputs, same outputs, every machine. That determinism costs performance, because some hardware SIMD instructions are faster but disagree at the edges.

So the working group shipped a second set: relaxed SIMD. <cite index="6-1">This proposal introduces non-deterministic instructions - given the same inputs, two calls to the same instruction can return different results.</cite>

The spec is blunt about what "relaxed" buys and what it leaks. <cite index="4-1">Local nondeterminism can take the form of different approximations, differing NaN behavior, differing rounding behavior, and differing response to some subset of inputs.</cite>

The categories, from the proposal itself: <cite index="6-1">integer instructions where the inputs are interpreted differently, floating-point instructions whose behavior for out-of-range and NaNs differ, and floating-point instructions where the precision or order of operations differ.</cite>

Read that as a defender and it reads like a menu. Each "implementation-defined" branch is a place where two CPUs, or two engines, or one engine on two architectures, are allowed to disagree. And disagreement is entropy.

The working group saw it coming. On nearly every instruction proposal, someone asked the same question in the issue tracker: <cite index="12-1">"How does behavior differ across processors? What new fingerprinting surfaces will be exposed?"</cite>

Mozilla's platform reviewers were more direct. On the intent-to-ship thread, one engineer noted that <cite index="4-1">this nondeterministic behaviour turns out to be a really great source of fingerprinting data in web audio.</cite>

So this isn't a clever exploit found in the wild. It's a documented, deliberated property of a shipping web standard, live in Chrome since version 114, Firefox since 120, and Safari since 18.4. That puts it across all three major engines as of 2026.

Which raises the obvious question. If you get one implementation-defined bit out of an instruction, so what? One bit is nothing.

The answer is that the bits aren't random. They cluster hard by architecture. And a few of them separate the two architectures that matter most for bot detection, x86 and ARM, cleanly enough to act on.

That's the next section.

Relaxed swizzle splits x86 from ARM in a single instruction

The cleanest discriminator in the whole set is relaxed i8x16.swizzle. One instruction, one comparison, and you've separated x86-class hardware from ARM.

Swizzle is a table lookup. You hand it a 16-byte vector a and a 16-byte index vector s, and for each lane it returns a[s[i]]. Simple, until an index points outside the table.

The spec defines the out-of-range case as nondeterministic:

def relaxed_i8x16_swizzle(a, s):
    for i in range(16):
        if s[i] < 16:
            result[i] = a[s[i]]           # in range: deterministic
        elif s[i] < 128:
            result[i] = IMPL_DEFINED_ONE_OF(0, a[s[i] % 16])
        else:
            result[i] = 0                 # high bit set: always 0
    return result

The interesting band is an index in [16, 127]. The spec says the engine may return either 0 or a[s % 16]. It does not say which. The hardware decides.

And the hardware decides consistently, because of how each architecture's native shuffle works.

On x86, the lowering target is PSHUFB. Per the Intel manual and confirmed in the Wasm SIMD issue tracker, <cite index="21-1">PSHUFB uses the four least significant bits to decide which lane to grab from a vector. If the most significant bit is one, then the result is zeroed.</cite> So an index of 0x11 (decimal 17, high bit clear) becomes 17 & 0x0F = 1, and the instruction returns a[1], a nonzero, meaningful byte.

On ARM64, the lowering target is TBL. <cite index="12-1">ARM/ARM64, vtbl and tbl, out of range indices return 0.</cite> Same index 0x11, and TBL returns 0.

There's your bit. Feed the swizzle an index vector full of 0x11 against a table where a[1] = 0xAB, and:

  • x86 returns 0xAB
  • ARM returns 0x00

That is a one-instruction architecture classifier. No timing, no statistics, no machine-learning model. Just a direct read of a value the CPU is architecturally bound to produce.

The second layer of mechanism is what makes this stick, and it's worth understanding because it's exactly what an evader has to defeat.

The non-relaxed swizzle, the deterministic one every browser has shipped since 2021, goes out of its way to erase this difference. On x86 the engine can't just emit PSHUFB, because raw PSHUFB would leak the a[s%16] behavior and break determinism. So it sanitizes the index first.

Firefox's fix is documented in Bugzilla: <cite index="18-1">the mask vector must first be sanitized so that out-of-range lanes in the mask have the high bit set</cite>, done by saturating-adding a constant so every out-of-range index forces the zero path. A teardown of V8's lowering describes the same three-instruction dance on x86-64 (splat 0x70, saturating-add into the index vector, then PSHUFB) precisely to make x86 match ARM's zero behavior.

Relaxed swizzle removes that sanitization. That's the entire point of the "relaxed" variant: skip the fix-up instructions, emit the bare hardware shuffle, take the speed, accept the divergence. The divergence you accept is the fingerprint you read.

Consequence for a fraud team: you now have a cheap, deterministic bit that reports the architecture of the browser binary. And that architecture is something a bot operator running an ARM server farm has to actively work around, not just declare.

Where it stops. This is a coarse signal, not a device ID. It separates x86 from ARM and not much else. An AMD Zen 4 and an Intel Raptor Lake both run PSHUFB and return a[s%16], so they look identical here. It tells you architecture family, full stop.

And it's engine-mediated. If a future V8 or SpiderMonkey release decides to sanitize relaxed swizzle too (nothing in the spec forbids returning 0 on x86), the bit flips for every user on that version at once. You're reading an implementation choice, and implementation choices change on the vendor's schedule, not yours. Instrument the browser version alongside the bit, or a Chrome update quietly reclassifies your entire traffic.

The other relaxed ops each leak a smaller, correlated bit

Swizzle is the sharpest, but it isn't alone. The relaxed set is a cluster of implementation-defined branches, and several of them split along the same architectural seam. Together they turn one noisy bit into a short, stable vector.

Float-to-int truncation is the next useful one. relaxed i32x4.trunc_f32x4_s matches the deterministic version for in-range values, then diverges on the edges. Per the spec pseudocode, a NaN input returns IMPL_DEFINED_ONE_OF(0, INT32_MIN), and an out-of-range positive value returns IMPL_DEFINED_ONE_OF(INT32_MIN, INT32_MAX).

Feed it +Infinity cast to i32 and you get either 0x7FFFFFFF or 0x80000000 depending on how the host saturates. That's an architectural constant you can read in one call.

Relaxed min and max leak a different edge. <cite index="6-1">If either value is NaN, or the values are -0.0 and +0.0, the return value is implementation-defined.</cite> The -0.0 versus +0.0 case is the reliable probe: relaxed_min(-0.0, +0.0) returns whichever operand the hardware's MINPS/FMIN happens to yield, and x86 and ARM made different choices there decades ago.

Relaxed FMA is the richest source of entropy and the hardest to read cleanly. The relaxed_madd(a, b, c) op computes a * b + c with <cite index="6-1">either the intermediate a * b rounded first and the final result rounded again, for a total of two roundings, or the entire expression evaluated with higher precision and then only rounded once, if supported by hardware.</cite>

That single-versus-double rounding difference is measurable. Pick inputs where the two rounding orders land on different representable floats — the low mantissa bits do the work, and the result tells you whether the host fused the operation in hardware. Modern x86 with FMA3 and modern ARM both fuse; a software fallback rounds twice. So this one leans toward "does the hardware have native FMA," which correlates with architecture and age.

Here's the worked version. Take a = b = 1 + 2^-23 (the smallest float above 1.0) and c = -1. Computed with one rounding, a*b - 1 keeps a low-order bit that a two-rounding path discards. Read the last mantissa bit of the result and you've read the host's rounding policy.

The mechanism underneath all four is the same, one level down: WebAssembly deliberately declined to specify these edges so that engines could emit the single fastest native instruction instead of a portable emulation. Every edge left unspecified is an edge where the native instruction's personality shows through. Intel's PSHUFB, ARM's TBL, and their float units have different personalities, and relaxed SIMD is a straw through the sandbox that lets you taste them.

Consequence for the reader: you don't rely on any single branch. You run four or five of them, concatenate the results into a small vector, and treat that as the architecture signal. One flipped bit from an engine update degrades the vector; it doesn't destroy it.

The counter-case is important and it's a genuine dispute among the people who wrote the spec. When Ralf Jung opened issue #154 in November 2023, his point was that relaxed SIMD's nondeterminism isn't new: <cite index="11-1">the behavior of NaNs is explicitly documented as non-deterministic, its most significant bit is 1 and all others are unspecified.</cite> Plain Wasm already leaked NaN payload bits before relaxed SIMD existed. If you were fingerprinting NaN canonicalization in 2022, relaxed SIMD didn't hand you a new capability so much as a larger, cleaner one. Which side does the evidence favor? Both, honestly. The NaN surface was always there, but the swizzle discriminator is categorically sharper than reading stochastic NaN bits, and that sharpness is new.

Timing benchmarks go deeper — and cost you reliability to do it

Result-based fingerprinting tops out at architecture family. If you want the actual CPU vendor, microarchitecture, or model, you stop reading values and start reading the clock. This is the older, better-studied branch of the field, and its numbers are strong, with a catch attached to every one of them.

The anchor result is Trampert et al., "Browser-based CPU Fingerprinting," presented at ESORICS 2022 by a team at CISPA. They built side-channel benchmarks that run in unmodified browsers via JavaScript and Wasm and read out microarchitectural properties like cache sizes and associativities.

The numbers, from a study of <cite index="29-1">834 participants using 297 different CPU models</cite>: <cite index="29-1">combining multiple properties allows identifying the CPU vendor with an accuracy of 99%, and the microarchitecture and CPU model each with an accuracy of above 60%.</cite>

Read those two figures carefully, because the gap between them is the whole point. Vendor (Intel versus AMD versus ARM) falls out at 99%. Exact model lands above 60%. Coarse questions get sharp answers; fine ones get soft ones. That shape holds across almost every hardware-fingerprinting method.

Their port-contention follow-up went finer. An automated framework searched for instruction sequences that contend for specific execution ports, and across 50 CPUs it could <cite index="30-1">determine the CPU generation within 12 seconds with 95% accuracy.</cite> Twelve seconds is an eternity for a login flow, which tells you where this technique lives: slow-path risk analysis, not the synchronous allow/block decision.

The most defense-relevant recent work is the 2025 preprint "Browser Fingerprinting Using WebAssembly." Its method times JavaScript-to-Wasm interactions and CPU-bound loops, and the headline is the false-positive discipline: it <cite index="28-1">distinguishes between Chromium-based browsers like Google Chrome and Microsoft Edge, even when identifiers such as the User-Agent are completely spoofed, achieving a false-positive rate of less than 1%.</cite>

For anyone hunting bots in datacenters, they <cite index="28-1">validated the approach across Intel, AMD, and ARM CPUs, operating systems including Windows, macOS, Android, and iOS, and environments like VMware, KVM, and VirtualBox.</cite> The VM coverage matters: timing benchmarks that survive virtualization are timing benchmarks that can flag a headless browser running in a cloud instance.

Now the catch, and it applies to every timing number above. Time is noisy. The signal you're reading is contaminated by CPU frequency scaling, thermal throttling, background load, and the browser's own deliberate clock-blunting. performance.now() is coarsened to milliseconds and jittered in most browsers specifically to kill timing side channels, so these methods reconstruct precision from repeated measurement, which means they need time, quiet, and repetition to be reliable.

The worked consequence: a benchmark that reads 99% vendor accuracy in a lab with a warm cache and an idle core degrades on a real user's mid-tier Android phone that's simultaneously decoding video, throttling for heat, and sharing the core with three other tabs. The named victim here is easy to picture: a legitimate shopper on a two-year-old phone in a warm room, whose noisy timings drift toward the profile of an emulated device. You measured a CPU; you also measured their afternoon.

Counter-position worth stating: Trampert et al. argue their benchmarks are <cite index="32-1">unaffected by current side-channel and browser fingerprinting mitigations.</cite> That's true of the property-inference benchmarks they built, which read structural facts like cache associativity that don't depend on fine timer resolution. It's less true of raw port-contention timing, which reduced-resolution timers and Firefox's resistFingerprinting do degrade. When a lab says "mitigations don't affect us," check which of their signals they mean.

What would change our mind on timing as a primary bot signal: a published measurement showing stable per-model classification on mobile hardware under realistic background load, not just desktop CPUs in a controlled study. We haven't seen it.

For bot detection, the value is the consistency check — not the device ID

Here's the reframe the privacy literature mostly skips, because it's written about tracking users, not catching bots.

For a fraud team, a CPU fingerprint is nearly useless as a stable identifier and quite valuable as a contradiction detector. You are not trying to recognize a returning device. You are trying to catch a client whose claimed environment and actual environment don't match.

The relaxed-swizzle bit is built for exactly this.

A client tells you what it is through channels an operator controls completely: the User-Agent string, navigator.platform, and in Chromium the User-Agent Client Hints architecture value from getHighEntropyValues(["architecture"]). All spoofable, all cheap to lie about.

The swizzle bit tells you what the browser binary is actually running on. That's controlled by physics and the JIT, not by a header the operator edits.

So the detection isn't "identify the CPU." It's "does the claimed architecture match the observed one?"

claimed  = uaClientHints.architecture      // "x86" | "arm"; attacker-controlled
observed = wasmSwizzleArch()               // "x86" | "arm"; engine/hardware-derived

if claimed == "x86" and observed == "arm":
    signal.arch_mismatch = true            // a feature, never a verdict

The tuning discussion is the substantial part, so let's be precise about what this feature is worth and what it costs.

arch_mismatch fires on a real and common bot pattern: automation running on cheap ARM cloud instances or ARM server farms, dressed up with a Windows-on-Intel User-Agent because that's what the majority of the legitimate traffic looks like. The operator spoofed the headers and never touched the Wasm engine, because normalizing the swizzle bit means running the browser on actual x86 hardware or patching the runtime. That's a real cost jump from editing a string.

But you must never block on this bit alone, and the false-positive population tells you why.

Consider the developer on an Apple Silicon MacBook (ARM hardware) who runs an older x86 build of an Electron app, or an x86 browser, under Rosetta 2. Rosetta faithfully translates x86 semantics, so V8 emits PSHUFB and the swizzle bit reads x86. Meanwhile some environment surfaces report the ARM host. Observed and claimed can legitimately disagree on the same physical machine. Block on the mismatch and you've thrown a 403 at a paying developer customer sitting on a Mac.

Put rough numbers on it. If Apple Silicon is on the order of 10% of your desktop macOS traffic, and even a small fraction of those users run something under translation, a hard block on arch_mismatch walks straight into a few thousandths of your Mac desktop base: low as a rate, loud as a support queue, and concentrated in exactly the high-value technical users you least want to annoy. The correct move is to treat the bit as one weak feature among many, route mismatches to a step-up challenge rather than a block, and let the rest of the signal stack decide.

Second failure population: absence. Relaxed SIMD only shipped everywhere in the last two years. A user on Firefox 118, or Safari on iOS 17, has no relaxed swizzle at all. WebAssembly.validate on a relaxed module returns false. If your pipeline reads "no signal" as "evasion," you've just penalized everyone on a slightly old browser, which skews older, poorer, and more mobile than your average. Missing is not lying. Instrument the three states (matches, mismatches, and absent) as distinct, never collapse the last two.

What would change our mind about relying on it more heavily: evidence that a common bot toolkit has started shipping a patched Wasm engine that normalizes the swizzle bit. The day that's commodity, this signal decays from "cheap contradiction detector" to "thing sophisticated operators already handle," and it drops down the stack. Until then, it's a genuinely cheap bit that costs an operator real money to defeat.

What doesn't work

Four approaches look reasonable on a whiteboard and fail in production. Each is worth naming because each is a mistake somebody ships every quarter.

Treating the fingerprint as a stable device identifier. It isn't one, and the math says so. The strongest result-based signal resolves architecture family: one or two bits. The strongest timing signal hits <cite index="29-1">above 60% on exact CPU model</cite>, which means it's wrong on the model more than a third of the time. Neither gives you a durable per-device key, and if you build device-linking on top of a signal that's coarse-by-construction, you'll merge distinct users who happen to share an Intel laptop generation and split single users whose timings drifted between sessions. Cache-based device identifiers were never the promise here; the CISPA work is explicit that model-level identification is the soft end of its range.

Blocking on architecture mismatch alone. Covered above, but it earns its place on this list because it's the single most tempting error. The Rosetta 2 translation case, corporate-imaged fleets, and legitimate emulators all generate honest mismatches. A mismatch is evidence; a mismatch is not a bot. Anyone who wires arch_mismatch straight to a block learns this from their chargeback team within a week.

Leaning on raw timing for the synchronous decision. The port-contention method needs <cite index="30-1">12 seconds to reach 95% on CPU generation.</cite> Your allow/block budget is tens of milliseconds. Timing fingerprints belong in the asynchronous risk lane (post-auth review, velocity scoring, session-long trust), not the gate. Teams that try to force a precise timing read into the login handler get neither the accuracy nor the latency, because the browser's coarsened performance.now() guarantees that a single quick measurement is mostly noise.

Assuming the values are engine-independent hardware truth. They aren't. The swizzle bit is a joint function of hardware and the engine's lowering choice, and the working group has already relitigated whether these edges should stay unspecified. Firefox sanitizes the deterministic swizzle today; nothing stops a vendor extending that to the relaxed variant, or shipping a determinism profile that pins every relaxed op to a fixed result. The extended discussion around adding <cite index="5-1">a deterministic FMA</cite> shows the committee actively weighing exactly these tradeoffs. Read the bit as "engine version X on architecture Y," pin the browser version to every observation, and expect the mapping to move.

The through-line: every dead end here comes from treating a probabilistic, engine-mediated, deliberately-coarse signal as if it were a precise hardware read. It never was. The vendors told you so in the issue tracker.

How we measured this, and what we didn't

Method. The architecture-discrimination claims in this piece are derived from primary specification material, not from a device-fleet measurement we're reporting here. The relaxed-SIMD semantics come from the WebAssembly relaxed-simd proposal Overview and the webassembly.org nondeterminism document. The x86-versus-ARM lowering behavior comes from the Wasm SIMD and relaxed-SIMD issue trackers, Intel's documented PSHUFB semantics, and Mozilla's Bugzilla record of how the deterministic swizzle is sanitized. The worked byte-level examples are traced directly through the spec's pseudocode.

External data. The timing-fingerprinting figures (834 participants and 297 CPU models, ~99% vendor accuracy, above 60% model accuracy, 12-second/95% CPU-generation classification, and the sub-1% false-positive Wasm-timing result) are from Trampert et al. (ESORICS 2022) and the 2025 preprint "Browser Fingerprinting Using WebAssembly," cited inline. We did not reproduce them.

Limitations. We have not published a first-party measurement of relaxed-swizzle stability across a production traffic sample, so we cannot state the real-world distribution of the three signal states (match, mismatch, absent) on live traffic, nor the true false-positive rate of arch_mismatch on a specific merchant base. Those depend on your Apple Silicon share, your browser-version mix, and your emulation footprint, all of which we'd have to measure per deployment. Reported published accuracies are lab figures; expect degradation on loaded mobile hardware. Anyone quoting a single detection rate without its false-positive cost, its browser-version dependence, and the population it was measured against is quoting a number that doesn't survive contact with a real fraud queue.

Wrapping up

Instrument one thing this week: the relaxed-swizzle architecture bit, recorded as a three-state feature (matches the claimed architecture, contradicts it, or is absent), with the browser version stored next to every reading. Don't wire it to a block. Feed it to whatever already weighs your weak signals, and watch how mismatches correlate with the fraud you already catch by other means.

The mental model to keep: this is a contradiction detector, not a device ID. Its power is that lying is cheap on the header side and expensive on the engine side, so a mismatch means an operator either got sloppy or hasn't paid the cost yet. Its weakness is that it's coarse, engine-mediated, and honestly triggered by Rosetta 2 and old browsers.

Re-check in six months. The two things that move this signal are a browser vendor pinning relaxed ops to a determinism profile, and a bot toolkit shipping a patched engine that normalizes the bit. Either one changes the math. Watch the WebAssembly determinism discussions and your own version-tagged mismatch rate; when the mapping shifts, you'll see it in your data before you read it in a changelog.

David Fumuman

Written by David Fumuman

I love understanding the technology and deeper meanings behind code. Truely.