Introduction
Modern browsers expose WebGPU as a low‑level graphics and compute API. The appeal is obvious: developers can ship tiny neural nets that run entirely in the user’s browser, avoiding round‑trips to a backend. However, executing inference inside a raw GPU shader opens a set of subtle privacy and integrity problems that are rarely discussed in mainstream tutorials. This article explains those risks and demonstrates a safer architecture that isolates model data behind a Web Worker while still using WebGPU for the heavy lifting.
What Makes Direct Shader Inference Risky?
When a model is compiled into WGSL (WebGPU Shading Language) and submitted to the GPU, the binary blob becomes part of the page’s public assets. An attacker who can inspect the GPU command stream (via a malicious extension or a compromised browser) can recover the weights, reconstruct the model, or infer the data that passed through it. Moreover, GPUs share memory with other tabs, and without explicit isolation, a rogue script can launch side‑channel attacks that read residual buffer contents.
The following points summarize the hidden liabilities:
- Model weights are delivered in clear text as part of the JavaScript bundle.
- GPU buffers are not automatically zeroed after use, leaving traces for the next WebGPU context.
- Shader execution time can reveal input size or classification confidence through timing analysis.
- Cross‑origin resource sharing (CORS) does not protect against a malicious page that loads the same shader from a public CDN.
Step‑by‑Step: Unsafe Direct Inference
The code below shows a minimal example of loading a tiny MNIST classifier directly into a shader. This is the pattern you will find in many “run‑your‑model‑in‑the‑browser” tutorials.
// Unsafe: model bytes are embedded in the page
const modelWgsl = `@group(0) @binding(0) var<storage, read> weights: array<f32>;
@group(0) @binding(1) var<storage, read_write> input: array<f32>;
@group(0) @binding(2) var<storage, read_write> output: array<f32>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id : vec3<u32>) {
let i = id.x;
if (i < arrayLength(&output)) {
var sum : f32 = 0.0;
for (var j : u32 = 0u; j < arrayLength(&weights); j = j + 1u) {
sum = sum + weights[j] * input[j];
}
output[i] = sum;
}
}`;
async function runInference(inputArray) {
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();
const shaderModule = device.createShaderModule({code: modelWgsl});
// ... buffer creation, pipeline setup, dispatch ...
}
Notice how the modelWgsl string contains the raw weight array. Any user can view modelWgsl in DevTools, copy the numbers, and reconstruct the model offline.
Safer Architecture: Isolate the Model in a Dedicated Worker
The recommended pattern moves the model data into a Web Worker that never exposes the weight buffer to the main thread. The main thread only sends input tensors and receives output tensors. The worker still uses WebGPU for compute, but it enforces a strict message‑passing contract.
// model-worker.js – runs in a separate thread
self.addEventListener('message', async (event) => {
const {type, payload} = event.data;
if (type === 'init') {
// Load encrypted weight file from server (fetch + decryption)
const response = await fetch(payload.weightsUrl);
const encrypted = await response.arrayBuffer();
const key = await crypto.subtle.importKey(
'raw', payload.key, {name: 'AES-GCM'}, false, ['decrypt']);
const iv = new Uint8Array(encrypted.slice(0, 12));
const ciphertext = encrypted.slice(12);
const decrypted = await crypto.subtle.decrypt(
{name: 'AES-GCM', iv}, key, ciphertext);
self.weights = new Float32Array(decrypted);
self.device = (await navigator.gpu.requestAdapter()).requestDevice();
self.pipeline = self.device.createComputePipeline({
compute: {module: self.device.createShaderModule({code: payload.wgsl}), entryPoint: 'main'}
});
self.bindGroupLayout = self.pipeline.getBindGroupLayout(0);
self.postMessage({type: 'ready'});
} else if (type === 'infer') {
const input = new Float32Array(payload.input);
// Create GPU buffers (weights are never sent to main thread)
const weightBuf = self.device.createBuffer({
size: self.weights.byteLength,
usage: GPUBufferUsage.STORAGE,
mappedAtCreation: true
});
new Float32Array(weightBuf.getMappedRange()).set(self.weights);
weightBuf.unmap();
const inputBuf = self.device.createBuffer({
size: input.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
mappedAtCreation: true
});
new Float32Array(inputBuf.getMappedRange()).set(input);
inputBuf.unmap();
const outputBuf = self.device.createBuffer({
size: 4 * 10, // assume 10‑class output
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
});
const bindGroup = self.device.createBindGroup({
layout: self.bindGroupLayout,
entries: [
{binding: 0, resource: {buffer: weightBuf}},
{binding: 1, resource: {buffer: inputBuf}},
{binding: 2, resource: {buffer: outputBuf}}
]
});
const commandEncoder = self.device.createCommandEncoder();
const pass = commandEncoder.beginComputePass();
pass.setPipeline(self.pipeline);
pass.setBindGroup(0, bindGroup);
pass.dispatchWorkgroups(1);
pass.endPass();
self.device.queue.submit([commandEncoder.finish()]);
// Read back result
const readBuf = self.device.createBuffer({
size: outputBuf.size,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
});
const copyEncoder = self.device.createCommandEncoder();
copyEncoder.copyBufferToBuffer(outputBuf, 0, readBuf, 0, outputBuf.size);
self.device.queue.submit([copyEncoder.finish()]);
await readBuf.mapAsync(GPUMapMode.READ);
const resultArray = new Float32Array(readBuf.getMappedRange()).slice();
readBuf.unmap();
self.postMessage({type: 'result', payload: resultArray});
}
});
Key improvements:
- Weights are stored encrypted on the server and only decrypted inside the worker.
- The main thread never receives the weight buffer, eliminating accidental exposure.
- All GPU resources are created inside the worker, ensuring that the browser’s per‑origin isolation keeps buffers separate from other tabs.
- Message passing enforces a clear contract, making
♥ Enjoyed this article? Use the like banner at the top of the page to let us know — you can like it more than once! Each additional like from the same reader carries a little less weight than the first, so our appreciation scores reflect genuine enthusiasm rather than accidental clicks. Your feedback helps us understand which topics resonate most.