Using WebCrypto subtle in Edge Runtimes

This guide is part of Supported Web APIs in Edge Runtimes. It shows how to replace Node’s crypto module with crypto.subtle for the operations edge middleware actually needs: verifying a webhook signature, hashing a request body, and signing a short-lived token — all without a network call and without a Node shim.

crypto.subtle is the only cryptography primitive edge runtimes give you, and it is deliberately awkward. Everything is a promise, keys are opaque handles rather than strings, and there is no synchronous escape hatch. Each of those frictions exists for a reason, and understanding the reason is what stops you from working around it in a way that reintroduces the vulnerability it was designed to prevent.

The problem

You port a webhook receiver to the edge. The Node original was four lines:

const expected = crypto.createHmac('sha256', secret).update(body).digest('hex');
if (expected !== req.headers['x-signature']) return res.status(401).end();

At the edge, createHmac does not exist. You find crypto.subtle.sign, discover it returns a promise, discover it wants a CryptoKey rather than a string secret, discover importKey is also async, and end up with a function that awaits three times and returns an ArrayBuffer you then have to hex-encode by hand. Somewhere in that translation it becomes tempting to compare the hex strings with ===, which is exactly the mistake the Node version was already making.

Then it works, and profiling shows every request spending an extra 0.4 ms re-importing the same key.

Root cause: an async-only API over opaque key handles

Node’s crypto module is unavailable at the edge, as covered in polyfilling Node crypto in edge runtimes. What you get instead is the Web Crypto API, and its subtle namespace is asynchronous by design rather than by accident.

Three reasons drive that. First, the key material may not live in your address space. A CryptoKey is a handle; a conforming implementation may keep the bytes in a separate process or a hardware module, so every operation is potentially a cross-boundary call. Second, operations can be genuinely slow — an ECDSA signature is milliseconds of arithmetic, and a synchronous API would block the isolate’s single thread, which at the edge means blocking every concurrent request that isolate is serving. Third, the API is designed to be misuse-resistant. Keys carry a usages list and an extractable flag, so a key imported for verification cannot be used to sign, and a non-extractable key cannot be exported back to bytes.

The performance consequence is the one that shapes your code. importKey is the expensive step — it parses, validates, and materialises the key — and it is pure with respect to its inputs. Import once at module scope, reuse for the isolate’s lifetime, exactly as you would a JWKS document.

Anatomy of a signed webhook request The timestamp header and the raw request body are concatenated into a signing string. That string is HMAC-signed with the shared secret and compared against the signature header. POST /webhooks/billing x-timestamp: 1785312000 x-signature: v1=9f2c...ab content-type: application/json body: {"event":"invoice.paid"} signing string timestamp + "." + raw body shared secret imported once HMAC-SHA-256 subtle.verify accept constant time 401, no detail and no retry hint The raw body bytes must be signed, never a re-serialised JSON object.
The signing string is part of the contract: get the concatenation or the encoding wrong and every signature mismatches for reasons no error message will reveal.

Step 1 — Import the HMAC key once, at module scope

importKey takes the format, the raw material, the algorithm description, an extractable flag, and the permitted usages. Cache the returned promise rather than the resolved key so concurrent first requests share one import.

// lib/hmac.ts
let keyPromise: Promise<CryptoKey> | null = null;

export function hmacKey(secret: string): Promise<CryptoKey> {
  // Module scope: evaluated once per isolate, shared by every request it serves.
  keyPromise ??= crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,                    // non-extractable: the bytes can never be read back
    ['sign', 'verify'],       // narrow usages; a verify-only key would omit 'sign'
  );
  return keyPromise;
}

Caching the promise rather than awaiting and caching the key matters under concurrency. If ten requests hit a cold isolate simultaneously and you cache only after await, all ten start their own import. Assigning the promise makes the ninth and tenth await the first one’s work.

Set extractable to false unless you genuinely need to export the key. A non-extractable key cannot be turned back into bytes by any code path, which limits the blast radius if an injection bug ever gets to read your variables.

Step 2 — Verify a webhook signature with subtle.verify

This is the core of the page. Notice what it does not do: it never computes an expected signature and compares it with ===.

// lib/webhook.ts
import { hmacKey } from './hmac';

export type VerifyFailure =
  | 'missing_header' | 'bad_format' | 'stale_timestamp' | 'bad_signature';

export type VerifyResult =
  | { ok: true; body: string }
  | { ok: false; reason: VerifyFailure };

const MAX_SKEW_SECONDS = 300;

function hexToBytes(hex: string): Uint8Array | null {
  if (hex.length % 2 !== 0 || /[^0-9a-f]/i.test(hex)) return null;
  const out = new Uint8Array(hex.length / 2);
  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
  return out;
}

export async function verifyWebhook(request: Request, secret: string): Promise<VerifyResult> {
  const header = request.headers.get('x-signature');
  const timestamp = request.headers.get('x-timestamp');
  if (!header || !timestamp) return { ok: false, reason: 'missing_header' };

  const signature = hexToBytes(header.startsWith('v1=') ? header.slice(3) : '');
  if (!signature) return { ok: false, reason: 'bad_format' };

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(age) || age > MAX_SKEW_SECONDS) {
    return { ok: false, reason: 'stale_timestamp' };
  }

  // Read the RAW body. Never JSON.parse then re-stringify: key order and
  // whitespace would change and the signature would never match.
  const body = await request.text();
  const signed = new TextEncoder().encode(`${timestamp}.${body}`);

  const valid = await crypto.subtle.verify(
    'HMAC',
    await hmacKey(secret),
    signature,
    signed,
  );

  return valid ? { ok: true, body } : { ok: false, reason: 'bad_signature' };
}

crypto.subtle.verify is the constant-time comparison. It computes the expected tag internally and compares it against the supplied signature without an early exit on the first differing byte. A string === on hex digests short-circuits, and the time it takes leaks how many leading bytes matched — enough, over many requests, to let an attacker forge a signature one byte at a time. There is no situation where hand-rolling that comparison is worth it: verify already exists, is already constant-time, and costs the same as sign.

The timestamp check is not optional either. Without it, a signature captured once is valid forever, and an attacker who observes one webhook can replay it indefinitely. Five minutes of skew is a reasonable ceiling.

Step 3 — Digest a body when you need a content hash

crypto.subtle.digest is the sha256sum equivalent. Use it when the signature scheme signs a hash of the body rather than the body itself, or when you want a stable cache key for a request payload.

// lib/digest.ts
export async function sha256Hex(input: string | Uint8Array): Promise<string> {
  const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : input;
  const digest = await crypto.subtle.digest('SHA-256', bytes);
  return [...new Uint8Array(digest)]
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');
}

export async function bodyDigestHeader(body: string): Promise<string> {
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body));
  // Base64 for the RFC 9530 style Content-Digest header.
  const b64 = btoa(String.fromCharCode(...new Uint8Array(digest)));
  return `sha-256=:${b64}:`;
}

digest returns an ArrayBuffer, not a string, and there is no built-in hex encoder — the map-and-pad idiom above is the standard workaround. For large bodies, prefer hashing the streamed chunks incrementally where your scheme allows it, because digest requires the whole input at once and therefore the whole body in memory; the streaming discussion in using web streams instead of Node streams at the edge applies directly.

Note that String.fromCharCode(...bytes) spreads the array onto the stack and will throw on inputs of a few hundred kilobytes. Loop for anything that is not a fixed-size digest.

Step 4 — Use ECDSA when the verifier must not be able to sign

HMAC has one property that eventually becomes a problem: the key that verifies is the key that signs. Anyone who can check a signature can mint one. When you are verifying signatures produced by a party you do not control — or when the verifying code runs somewhere less trusted than the signer — use an asymmetric algorithm so the edge only ever holds the public key.

// lib/ecdsa.ts
let pubKeyPromise: Promise<CryptoKey> | null = null;

// A P-256 public key in JWK form, safe to inline or ship as a plain env var.
const PUBLIC_JWK: JsonWebKey = {
  kty: 'EC',
  crv: 'P-256',
  x: 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU',
  y: 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0',
};

export function verifierKey(): Promise<CryptoKey> {
  pubKeyPromise ??= crypto.subtle.importKey(
    'jwk',
    PUBLIC_JWK,
    { name: 'ECDSA', namedCurve: 'P-256' },
    false,
    ['verify'],               // no 'sign' usage exists for a public key
  );
  return pubKeyPromise;
}

export async function verifyEcdsa(
  signature: Uint8Array,      // raw r||s, 64 bytes for P-256
  message: Uint8Array,
): Promise<boolean> {
  return crypto.subtle.verify(
    { name: 'ECDSA', hash: 'SHA-256' },
    await verifierKey(),
    signature,
    message,
  );
}

The algorithm argument differs between the two families and this trips people up: sign and verify for HMAC take the bare string 'HMAC' because the hash was fixed at import time, while ECDSA takes an object naming the hash at call time, because the same key can be used with different digests. Passing 'ECDSA' as a string throws a not-supported error with no hint about the missing hash.

The other ECDSA trap is signature encoding. Web Crypto expects the raw concatenation of r and s — 64 bytes for P-256 — while most server-side tooling emits ASN.1 DER. A DER-encoded signature handed to subtle.verify returns false, not an error, so it looks exactly like a wrong key.

Choosing an algorithm A decision tree starting from what needs proving. Integrity only leads to digest, a shared secret between two trusted parties leads to HMAC, and an untrusted verifier leads to ECDSA with a public key. What are you proving? bytes are unchanged no attacker in the model sender is authentic both sides trusted sender is authentic verifier must not sign digest SHA-256 no key at all HMAC SHA-256 one shared secret ECDSA P-256 public key at the edge
The deciding question is never "which is stronger" — it is whether the party doing the verifying should also be able to forge.

Step 5 — Wire it into the middleware and scope the matcher

// middleware.ts
import { verifyWebhook } from './lib/webhook';

export default async function middleware(request: Request): Promise<Response> {
  if (request.method !== 'POST') return new Response(null, { status: 405 });

  const result = await verifyWebhook(request, process.env.WEBHOOK_SECRET!);

  if (!result.ok) {
    console.log(JSON.stringify({ event: 'webhook.reject', reason: result.reason }));
    // One status for every failure: never tell a prober which check failed.
    return new Response(null, { status: 401, headers: { 'cache-control': 'no-store' } });
  }

  // The body was already consumed by verification, so forward it explicitly.
  return fetch(new Request(request.url, {
    method: 'POST',
    headers: request.headers,
    body: result.body,
  }));
}

export const config = {
  runtime: 'edge',
  matcher: ['/webhooks/:provider*'],
};

The comment about the consumed body is a real constraint, not a stylistic note. A Request body is a stream and can be read exactly once. Verification must read it to sign it, so the forwarded request has to be reconstructed from the string you already hold. Calling request.clone() before reading is the alternative, but it costs a second copy in memory for no benefit here. Secret handling itself is covered in using environment variables in Vercel Edge Middleware.

Key import cost, cold versus warm The first request in an isolate pays for key import as well as the HMAC verification. Every later request in that isolate reuses the cached key handle and pays only the verification cost. Request 1 importKey ≈ 0.4 ms verify Request 2 verify cached handle, no import Request N verify flat for the isolate's lifetime Caching the promise, not the resolved key, keeps concurrent cold requests to a single import.
Import cost is amortised across the isolate's whole lifetime, which is why it barely registers in production but dominates a cold benchmark.

Local versus production divergence

Behaviour Local dev Edge production
crypto.subtle availability Present in Node 18+, Miniflare and Deno Present natively on all three providers
Module-scope key cache Reset on every hot reload Lives for the isolate’s lifetime
Import cost visibility Dominant, because scope resets constantly Amortised to near zero
Clock for timestamp skew Your machine, possibly drifted PoP clocks tightly synchronised
process.env for the secret Loaded from a dotenv file Injected at build or bound at runtime
Non-extractable keys Enforced identically Enforced identically
Timing-attack surface Invisible on loopback Measurable over a real network

The import-cost row explains a common false alarm: a local benchmark shows key import as 30% of request time, because hot reload discards module scope on every save. In production that cost is paid once per isolate and disappears into the noise.

Provider notes

All three major runtimes expose crypto.subtle on the global crypto object with no import and no flag. Cloudflare Workers additionally exposes non-standard convenience digests such as MD5 for legacy interop, which you should avoid in new code; Vercel Edge Middleware and Netlify Edge Functions stick to the standard algorithm set. HMAC with SHA-256, SHA-384 and SHA-512, ECDSA on P-256/P-384/P-521, RSASSA-PKCS1-v1_5 and RSA-PSS are available everywhere. crypto.randomUUID() and crypto.getRandomValues() are synchronous on all three, because they do not involve key handles.

Validating with Vitest

Web Crypto is available in Node 18+, so the suite runs without any environment shim and exercises the exact code path production uses.

// webhook.test.ts
import { describe, expect, it } from 'vitest';
import { verifyWebhook } from './lib/webhook';
import { sha256Hex } from './lib/digest';

const SECRET = 'whsec_test_value';

async function sign(body: string, timestamp: string): Promise<string> {
  const key = await crypto.subtle.importKey(
    'raw', new TextEncoder().encode(SECRET),
    { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
  );
  const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${timestamp}.${body}`));
  const hex = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');
  return `v1=${hex}`;
}

function req(body: string, signature: string, timestamp: string): Request {
  return new Request('https://example.com/webhooks/billing', {
    method: 'POST',
    headers: { 'x-signature': signature, 'x-timestamp': timestamp },
    body,
  });
}

describe('verifyWebhook', () => {
  const now = () => String(Math.floor(Date.now() / 1000));

  it('accepts a correctly signed payload', async () => {
    const body = '{"event":"invoice.paid"}';
    const ts = now();
    const result = await verifyWebhook(req(body, await sign(body, ts), ts), SECRET);
    expect(result).toEqual({ ok: true, body });
  });

  it('rejects a payload whose body was altered after signing', async () => {
    const ts = now();
    const signature = await sign('{"amount":100}', ts);
    const result = await verifyWebhook(req('{"amount":10000}', signature, ts), SECRET);
    expect(result).toEqual({ ok: false, reason: 'bad_signature' });
  });

  it('rejects a replayed payload outside the skew window', async () => {
    const body = '{"event":"invoice.paid"}';
    const ts = String(Math.floor(Date.now() / 1000) - 3600);
    const result = await verifyWebhook(req(body, await sign(body, ts), ts), SECRET);
    expect(result).toEqual({ ok: false, reason: 'stale_timestamp' });
  });

  it('rejects a signature that is not valid hex', async () => {
    const ts = now();
    const result = await verifyWebhook(req('{}', 'v1=zzzz', ts), SECRET);
    expect(result).toEqual({ ok: false, reason: 'bad_format' });
  });

  it('produces a stable SHA-256 digest', async () => {
    expect(await sha256Hex('abc')).toBe(
      'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad',
    );
  });
});

The altered-body test is the one that catches the most damaging class of bug: an implementation that signs a re-serialised object rather than the raw bytes passes a naive round-trip test and fails against every real sender.

Common pitfalls and resolutions

Every signature mismatches, no error thrown — the signing string does not match the sender’s. Log the exact bytes you sign for one request and diff them against the provider’s documented construction, paying attention to separators and trailing newlines.

Signature verifies in Node but not at the edge — you re-serialised the JSON. JSON.parse followed by JSON.stringify changes key order and whitespace. Sign await request.text().

NotSupportedError on ECDSA sign or verify — the algorithm was passed as the string 'ECDSA'. It must be the object form naming the hash, unlike HMAC.

ECDSA returns false with a key you know is correct — the signature is DER-encoded. Web Crypto wants raw r || s, 64 bytes for P-256. Convert before verifying.

InvalidAccessError on sign — the key was imported with only ['verify'] in its usages, or a public key was passed to sign. Widen the usages deliberately, never reflexively.

Verification latency shows up in the p50importKey is inside the handler. Hoist it to module scope and cache the promise.

RangeError when base64-encoding a large body hashString.fromCharCode(...bytes) spread a big array onto the stack. Loop in chunks, or hash rather than encode.

Production deployment checklist

Frequently Asked Questions

Why is every crypto.subtle method asynchronous?

Because a CryptoKey is a handle rather than raw bytes, and a conforming implementation may keep the key material outside your address space, so operations are potentially cross-boundary calls. Signing is also real arithmetic that would block the isolate’s single thread, which at the edge means blocking every concurrent request that isolate is serving. There is no synchronous variant and no way around it.

Is comparing two hex digests with a string equality check really unsafe?

Yes. String equality short-circuits on the first differing character, so the time it takes leaks how many leading bytes matched. Over many requests that timing signal lets an attacker recover a valid signature one byte at a time. Use crypto.subtle.verify, which compares without an early exit and costs no more than computing the signature yourself.

Where should I cache an imported key?

In module scope, and cache the promise rather than the resolved key. Module scope lives for the isolate’s lifetime, so a warm request never re-imports. Caching the promise means that if ten requests hit a cold isolate at once, nine of them await the first import instead of starting their own.

Why does my ECDSA verification return false with the right key?

Almost always because the signature is ASN.1 DER encoded. Web Crypto expects the raw concatenation of r and s, which is 64 bytes for P-256, and a DER blob simply fails to match rather than raising an error. Convert the encoding before calling verify.

Should I use HMAC or ECDSA for webhook verification?

Use HMAC when you and the sender are the same organisation and can share one secret safely. Use ECDSA when the verifying code should not be able to forge a message, because the edge then holds only the public key and a leak from your worker cannot be used to mint valid webhooks.

Can I hash a request body without buffering all of it?

Not with crypto.subtle.digest, which requires the entire input at once. If the body is large enough for that to matter, either cap the accepted body size before verifying or use a signature scheme that covers a header-declared digest so you can stream and compare incrementally.