Web Crypto API in the browser vs Node.js: the differences that will burn you
Back in 2021, when I was making the jump from the Java world to TypeScript/Node.js, I carried one conviction with me: "web standards are standards, full stop." If something is called SubtleCrypto in the browser, it has to behave the same way in Node.js, right? The short answer: not exactly. The long answer is this post.
My thesis: crypto.subtle looks like a unified API — until you try to reuse the same encryption code across browser, Node.js 20+, and the Next.js edge runtime. The differences aren't philosophical — they're concrete, they're documented on MDN and in the official Node.js docs, and they show up at the worst possible moment: when the code is already tangled up inside a shared module.
Web Crypto API in browser, Node.js, and edge: one "standard," three flavors
The Web Crypto API defines an interface for cryptographic operations in the browser. Node.js implemented its own version under globalThis.crypto starting in v17, and marked it stable in v19. From Node.js 20 onward it's available globally — no import needed.
The Next.js edge runtime is a third environment: V8-based, no access to native Node.js APIs, with an explicit subset of Web APIs available as described in the official Next.js Edge Runtime docs.
In theory, all three expose crypto.subtle. In practice, all three have surface-level differences that matter once code is shared.
Accessing the crypto object
// Browser: global, no import needed
const key = await crypto.subtle.generateKey(/* ... */);
// Node.js 20+ — also global, no import required
// but before v19, you had to do this:
import { webcrypto } from 'node:crypto';
const key = await webcrypto.subtle.generateKey(/* ... */);
// Edge Runtime (Next.js Middleware, Route Handlers with `export const runtime = 'edge'`)
// crypto.subtle is available — but not every operation is guaranteed
const key = await crypto.subtle.generateKey(/* ... */);
The problem isn't access — it's that three environments with the same API surface don't support the exact same set of algorithms, nor the same parameters for every operation.
The concrete differences nobody reads until something breaks
1. Available algorithms: not all of them exist in all three environments
The W3C spec defines a set of algorithms for SubtleCrypto. Node.js implements them based on its internal OpenSSL version. The edge runtime has additional restrictions because of its V8-only environment.
According to the Node.js Web Crypto documentation, some algorithms like Ed25519 and X25519 were marked stable in specific Node.js versions. If your code runs on Node.js 18 and on an edge runtime that doesn't have those algorithms, the same generateKey call with { name: 'Ed25519' } can work in one place and throw DOMException: Unrecognized name in the other.
// This can fail silently in more restrictive edge runtimes
// Always verify against: https://nextjs.org/docs/app/api-reference/edge
const keyPair = await crypto.subtle.generateKey(
{
name: 'Ed25519', // ← algorithm NOT guaranteed in edge
},
true,
['sign', 'verify']
);
For symmetric encryption, AES-GCM is the most portable algorithm across all three environments. It has the best documented coverage both on MDN and in the Node.js implementation.
// AES-GCM: the one that travels best across browser, Node.js, and edge
async function generateKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey(
{
name: 'AES-GCM',
length: 256, // 128 or 256 bits — both supported
},
true, // extractable: required to export/import across contexts
['encrypt', 'decrypt']
);
}
async function encrypt(
key: CryptoKey,
data: string
): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> {
const iv = crypto.getRandomValues(new Uint8Array(12)); // 12 bytes for GCM
const encoder = new TextEncoder();
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
encoder.encode(data)
);
return { ciphertext, iv };
}
2. crypto.getRandomValues vs crypto.randomBytes: they are not interchangeable
This is the most common mistake I see when someone migrates Node.js code to the browser or the edge.
// ❌ This is native Node.js — NOT available in browser or edge runtime
import { randomBytes } from 'node:crypto';
const iv = randomBytes(12);
// ✅ This works in all three environments
const iv = crypto.getRandomValues(new Uint8Array(12));
randomBytes belongs to Node.js's native API (node:crypto), not the Web Crypto API. In a module shared between Next.js App Router (server components), Middleware (edge), and client code, that import blows up silently or with a module-not-found error.
3. Exporting and importing keys: the format matters
When you need to persist a key or pass it between contexts, crypto.subtle.exportKey and importKey work with specific formats. The mistake here isn't environment-specific — it's about key format.
// Export an AES key for storage (e.g., in sessionStorage or Redis)
async function exportKey(key: CryptoKey): Promise<string> {
const raw = await crypto.subtle.exportKey('raw', key);
// Convert to base64 for serialization
return btoa(String.fromCharCode(...new Uint8Array(raw)));
}
// Import back from base64
async function importKey(base64: string): Promise<CryptoKey> {
const raw = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
return crypto.subtle.importKey(
'raw',
raw,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
}
What changes between environments: btoa and atob are globals in browser and edge. In Node.js, btoa/atob have been globals since v16 — but if some module in the chain assumes they don't exist and uses Buffer.from(...).toString('base64') instead, you get silent inconsistency in serialization.
4. Next.js edge runtime: the subset that hurts
The Next.js Edge Runtime explicitly documents which APIs are available. crypto.subtle is on the list, but with the caveat that the V8 isolate environment has restrictions.
What this means for Middleware or Route Handlers with runtime = 'edge':
// app/api/token/route.ts with edge runtime
export const runtime = 'edge';
export async function POST(req: Request) {
// ✅ This works in edge
const iv = crypto.getRandomValues(new Uint8Array(12));
// ✅ AES-GCM works in edge
const key = await crypto.subtle.importKey(
'raw',
/* 32-byte buffer */,
{ name: 'AES-GCM' },
false,
['encrypt']
);
// ❌ DO NOT import 'node:crypto' here — edge runtime has no Node APIs
// import { createCipheriv } from 'node:crypto'; // Runtime error
}
The practical rule: if the Route Handler or Middleware runs in edge, use exclusively the Web Crypto API (crypto.subtle, crypto.getRandomValues). Nothing from node:crypto.
The errors that appear when you mix environments
Error 1: shared module that imports node:crypto
The most common scenario in a Next.js monorepo: an encryption function in lib/crypto.ts that uses node:crypto to get randomBytes or createCipheriv. That function travels fine to a Server Component or an API route with Node.js runtime. But if you ever use it in Middleware or a Route Handler with runtime = 'edge', the build compiles fine and the runtime explodes.
// ❌ lib/crypto.ts — NOT portable to edge
import { randomBytes, createCipheriv } from 'node:crypto';
// ✅ lib/crypto-portable.ts — works in all three environments
// Uses only Web Crypto API
export async function generateIV(): Promise<Uint8Array> {
return crypto.getRandomValues(new Uint8Array(12));
}
Error 2: assuming ArrayBuffers are the same everywhere
crypto.subtle.encrypt returns an ArrayBuffer. In Node.js, you can do Buffer.from(arrayBuffer) to convert it. In browser and edge, Buffer doesn't exist. If downstream code assumes Buffer, it fails in the browser.
// ✅ Portable — uses Uint8Array, not Buffer
function arrayBufferToHex(buffer: ArrayBuffer): string {
return Array.from(new Uint8Array(buffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
// ❌ Node.js only
// Buffer.from(buffer).toString('hex');
Error 3: SubtleCrypto.digest for hashing — watch out for SHA-1
crypto.subtle.digest supports SHA-1, SHA-256, SHA-384, and SHA-512 in all three environments. SHA-1 is there for compatibility but should never be used for anything new. The mistake here isn't environment-related — it's algorithm choice. If someone inherits code that uses SHA-1 in digest, it works everywhere, and that's exactly the problem.
// ✅ SHA-256 — portable and safe for hashing
async function hashText(text: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(text);
const hash = await crypto.subtle.digest('SHA-256', data);
return arrayBufferToHex(hash);
}
Decision checklist: before writing shared cryptographic code
Before creating an encryption module that's going to cross environments, run through this:
Where is this code going to run?
- [ ] Browser only → you can use
crypto.subtlewithout documented restrictions - [ ] Node.js only (Server Components, API routes without edge) → you can use global
crypto.subtleor nativenode:crypto, but don't mix them - [ ] Edge runtime (Middleware, Route Handler with
runtime = 'edge') → onlycrypto.subtleandcrypto.getRandomValues, zeronode:cryptoimports - [ ] Shared module across two or more of the above → the strictest restriction wins: pure Web Crypto API
Which algorithm?
- [ ] For symmetric encryption:
AES-GCMwith a 256-bit key — best documented portability - [ ] For hashing:
SHA-256or higher — neverSHA-1in new code - [ ] For signing:
ECDSAwithP-256has good coverage;Ed25519requires verifying support in the target environment before using it
How are you serializing the key?
- [ ] Using
btoa/atob(globals in Node.js 16+, browser, edge) orTextEncoder/TextDecoder(also globals in all three) - [ ] Avoiding
Buffer.from()in shared code
Will the build catch it?
- [ ] TypeScript with
"lib": ["ES2020", "DOM"]intsconfig.jsongives youSubtleCryptotypes. IfDOMis missing, the types won't resolve - [ ] If a module has
import from 'node:crypto', Next.js will warn you at build time for edge routes — pay attention to that warning
What you can't conclude without measuring
This matters: the official documentation from MDN, Node.js, and Next.js describes the API surface. What it does not describe is comparative performance across environments, or which one has better throughput for specific encryption operations.
If you need those numbers for an architecture decision — say, whether it's worth moving an encryption worker to edge instead of a Node.js runtime API route — that requires your own benchmark under real load conditions. I don't have those numbers publicly available. Nobody should sell you that claim without showing the data.
What you can conclude from official sources: the surface API is compatible for AES-GCM and SHA-256 across all three environments. The support differences for less common algorithms (Ed25519 curves, for example) are documented and verifiable right now.
FAQ: Web Crypto API across environments
Is crypto.subtle available globally in Node.js 20 without any import?
Yes. Since Node.js 19, globalThis.crypto is stable and requires no import. In Node.js 18 LTS it's available, but was still experimental for some algorithms. Check against the Node.js release notes for the specific algorithm you need.
Can I use node:crypto in a Next.js Server Component?
Yes, as long as that Server Component doesn't run in edge runtime. Server Components use Node.js runtime by default, where node:crypto is available. The conflict shows up if you ever move that component or module to the edge.
How do I know if my Route Handler runs in edge or Node.js?
If you don't declare export const runtime = 'edge' in the file, it runs in Node.js runtime by default. If you do declare it, it runs in edge and you have to respect the API subset documented in Next.js Edge Runtime.
Is AES-CBC also portable across all three environments?
According to MDN, AES-CBC is part of the Web Crypto spec. But AES-GCM is preferable because it includes encryption authentication (AEAD) and protection against ciphertext tampering. If you already have code with AES-CBC, it'll work in all three environments — but it's not the recommended choice for new code.
Why doesn't TypeScript warn me when I use APIs that don't exist in edge?
Because TypeScript types against the lib configuration in tsconfig.json, not against the actual runtime environment. If you configure "lib": ["ES2020", "DOM"], the SubtleCrypto types resolve correctly even if the code runs in edge. The error shows up at runtime, not at compile time. That's exactly why the manual environment checklist matters more than the types here.
Can I share cryptographic code between a React module and a Next.js Middleware without breaking anything?
Yes, if that module uses exclusively the Web Crypto API (crypto.subtle, crypto.getRandomValues) and avoids any import of node:crypto or Buffer. The quickest test: if the module compiles without errors with "target": "edge" in Next.js, you're on the right track.
The API is one, the environments are three, the contract is explicit
You don't need to distrust Web Crypto API to use it well. What you need is to read the documentation for each environment before writing the first shared module — not after the Middleware deploy explodes at 2am.
My concrete position: in Next.js projects that cross server, edge, and client, I either create separate encryption modules or I run through the algorithm and API checklist before any "let's unify this" refactor. The cost of one function per environment is minimal compared to debugging a runtime error in edge that TypeScript never caught.
What I don't buy: the idea that "it's all the same standard, nothing to verify." The standard defines the interface. The environments define what they implement of that standard. Those are different things.
If you're working on Next.js Middleware with authorization logic that touches encryption, the post on authorization patterns in Next.js 16 Middleware has useful complementary context. And if the shared module is part of a larger codebase with TypeScript strict mode, the post on strict mode in tsconfig might save you an extra surprise.
The practical next step: open the project's tsconfig.json, check the lib configuration, and search for any import from 'node:crypto' in modules that might cross over to edge. That alone tells you whether you have debt here.
Original sources:
- MDN Web Docs — Web Crypto API
- Node.js Docs — Web Crypto API
- Next.js Docs — Edge Runtime supported APIs
This article was originally published on juanchi.dev
Top comments (0)