Polyfilling node:crypto in Edge Runtimes
A dependency calls crypto.createHmac('sha256', secret) or crypto.randomBytes(16), and your edge build fails with Module "node:crypto" has been externalized or throws crypto.createHmac is not a function at runtime. The edge runtime does not ship Node’s crypto module — it ships WebCrypto (crypto.subtle), a different, promise-based, callback-free API. You cannot polyfill node:crypto byte-for-byte, but every common operation it does has a crypto.subtle equivalent.
This guide is part of Polyfill Strategies for Node.js APIs at the Edge. It maps the four operations that account for nearly all node:crypto usage — HMAC, SHA digests, random bytes, AES — onto WebCrypto, then covers the nodejs_compat flag and why it is a trap.
Root cause: two different crypto APIs, only one at the edge
node:crypto is a synchronous, streaming, Buffer-centric API built on OpenSSL. The edge runtime — a V8 isolate — has no OpenSSL and no Buffer. What it has is WebCrypto:
- Asynchronous. Every operation returns a
Promise. There is no synchronousdigest(). - Typed arrays, not Buffers. Inputs and outputs are
ArrayBuffer/Uint8Array, neverBuffer. - Explicit key import. You must
importKeybefore you can sign, verify, or encrypt.
So replacing node:crypto is a rewrite, not a shim. The good news: the surface most code uses is small. Cloudflare’s nodejs_compat flag can paper over some imports, but it does not make the synchronous Node API work the way the dependency expects — relying on it for crypto is fragile, as covered below.
Step 1: Replace createHmac with subtle.sign
HMAC is the most common use — signing webhooks, cookies, and tokens. Import the key once per algorithm, then sign.
// hmac.ts — replaces crypto.createHmac('sha256', key)
const encoder = new TextEncoder();
export async function hmacSha256(secret: string, message: string): Promise<string> {
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const sigBuffer = await crypto.subtle.sign('HMAC', key, encoder.encode(message));
// hex-encode without Buffer
return [...new Uint8Array(sigBuffer)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
To verify a webhook signature, compare in constant time rather than with === to avoid timing leaks (shown in the pitfalls).
Step 2: Replace createHash with subtle.digest
Plain hashing (createHash('sha256').update(x).digest('hex')) maps to crypto.subtle.digest.
// digest.ts — replaces crypto.createHash('sha256')
export async function sha256Hex(input: string): Promise<string> {
const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
return [...new Uint8Array(buffer)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
crypto.subtle.digest supports SHA-1, SHA-256, SHA-384, and SHA-512. MD5 is not available — if a dependency needs MD5, it must be replaced, not polyfilled.
Step 3: Replace randomBytes with getRandomValues
Random bytes come from crypto.getRandomValues (synchronous, unlike the rest of WebCrypto). For UUIDs, use crypto.randomUUID() directly.
// random.ts — replaces crypto.randomBytes(n)
export function randomBytes(length: number): Uint8Array {
return crypto.getRandomValues(new Uint8Array(length));
}
export function randomHex(length: number): string {
return [...randomBytes(length)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
getRandomValues is cryptographically secure at the edge. Never substitute Math.random() for security-sensitive values.
Step 4: Replace createCipheriv with subtle.encrypt (AES-GCM)
For symmetric encryption, AES-GCM is the WebCrypto equivalent of createCipheriv('aes-256-gcm', ...). It bundles the auth tag into the output.
// aes.ts — replaces crypto.createCipheriv('aes-256-gcm', key, iv)
export async function aesGcmEncrypt(rawKey: Uint8Array, plaintext: string) {
const key = await crypto.subtle.importKey('raw', rawKey, { name: 'AES-GCM' }, false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit nonce for GCM
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(plaintext),
);
return { iv, ciphertext: new Uint8Array(ciphertext) }; // tag is appended to ciphertext
}
The 12-byte IV must be unique per encryption and stored alongside the ciphertext for decryption. AES-GCM appends the authentication tag, so there is no separate getAuthTag() call.
Configuration
You can alias node:crypto away at build time so a stray import resolves to your WebCrypto shim, or — on Cloudflare — enable nodejs_compat with caveats (see pitfalls). Aliasing is the more predictable option.
// wrangler.jsonc — Cloudflare Workers
{
"name": "crypto-edge",
"main": "src/worker.ts",
"compatibility_date": "2026-01-01",
"compatibility_flags": ["nodejs_compat"],
"alias": {
"node:crypto": "./src/shims/crypto.ts"
}
}
// esbuild equivalent for Vercel / generic bundling
// build.ts
import { build } from 'esbuild';
await build({
entryPoints: ['src/worker.ts'],
bundle: true,
format: 'esm',
alias: { 'node:crypto': './src/shims/crypto.ts' },
});
Local vs production divergence
| Concern | Local dev | Production |
|---|---|---|
node:crypto import |
may resolve under Node dev server | externalized / unavailable at the edge |
nodejs_compat (Cloudflare) |
partial shim, looks like it works | partial — synchronous Node API still missing pieces |
crypto.subtle |
available | available, identical |
Buffer outputs |
present under Node | absent — use hex/Uint8Array helpers |
| MD5 / legacy ciphers | available under Node | unavailable at the edge |
| Timing-safe compare | crypto.timingSafeEqual exists |
must hand-roll a constant-time compare |
The trap: nodejs_compat makes the node:crypto import resolve, so a dependency loads, then fails at the first synchronous OpenSSL-backed call that the shim does not implement. Prefer rewriting to crypto.subtle over leaning on the compat layer.
Validation with Vitest
Test against known vectors so a refactor cannot silently change output. WebCrypto exists in Vitest’s environment, so these run without mocking.
// hmac.test.ts
import { describe, it, expect } from 'vitest';
import { hmacSha256 } from './hmac';
import { sha256Hex } from './digest';
describe('webcrypto polyfills', () => {
it('hmacSha256 matches a known vector', async () => {
// HMAC-SHA256("key", "The quick brown fox jumps over the lazy dog")
const sig = await hmacSha256('key', 'The quick brown fox jumps over the lazy dog');
expect(sig).toBe('f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8');
});
it('sha256Hex matches a known vector', async () => {
expect(await sha256Hex('abc')).toBe(
'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad',
);
});
});
Named pitfalls
- Expecting synchronous results. WebCrypto is promise-based;
subtle.digestdoes not return a value directly. Fix:awaitevery call and make callers async. - Comparing signatures with
===. Leaks timing information. Fix: compare byte-by-byte accumulating differences in constant time, or verify viacrypto.subtle.verify. - Reusing an AES-GCM IV. Reusing a nonce under the same key breaks GCM catastrophically. Fix: generate a fresh 12-byte IV per encryption with
getRandomValues. - Needing MD5 or legacy ciphers. WebCrypto does not provide them. Fix: switch the algorithm (SHA-256, AES-GCM); there is no edge-safe MD5.
- Trusting
nodejs_compatfor all ofnode:crypto. It is partial and synchronous gaps remain. Fix: rewrite the hot path tocrypto.subtleand alias the import to your shim.
Production deployment checklist
- No remaining
node:crypto - All crypto calls are
await - Signature comparison is constant-time or uses
- Random values come from
getRandomValues/randomUUID, never - Outputs encoded as hex/
Uint8Array, never
Frequently Asked Questions
Can I just import node:crypto at the edge?
No. Edge runtimes do not ship Node’s OpenSSL-backed crypto module. The import is externalized or throws at the first call. Use WebCrypto (crypto.subtle) instead, which is asynchronous and typed-array-based.
Does Cloudflare's nodejs_compat make node:crypto work?
Only partially. It resolves the import and shims some surface, but the synchronous OpenSSL-style API is not fully reproduced, so dependencies often fail at the first unsupported call. Rewrite the hot path to crypto.subtle and alias the import to your own shim rather than depending on the compat layer.
How do I hash with MD5 at the edge?
You cannot — WebCrypto does not provide MD5. If a dependency requires MD5, replace the algorithm with SHA-256 or vendor a pure-JS implementation. There is no edge-native MD5.
Why is my WebCrypto call returning a Promise instead of a value?
WebCrypto is asynchronous by design. crypto.subtle.digest, sign, and encrypt all return Promises, unlike Node’s synchronous crypto. Await every call and make the surrounding function async. The exception is crypto.getRandomValues, which is synchronous.