Sign JSON Data: HMAC-SHA256, JWS, and JCS Signatures
Last updated:
Signing JSON data proves its integrity and authenticity — the three main approaches are HMAC-SHA256 (shared secret), JWS compact serialization (RFC 7515, asymmetric keys), and JSON Canonicalization Scheme (JCS, RFC 8785) combined with any signature algorithm. A raw HMAC-SHA256 signature over JSON is fragile: key ordering and whitespace changes break verification even when the logical value is identical. JCS canonicalizes first — sorting keys, removing whitespace, normalizing numbers — before signing, making the signature stable across serializers.
JWS sidesteps the canonicalization problem differently: it Base64URL-encodes the payload bytes before signing, so the signature input is deterministic regardless of JSON formatting. This guide covers all three approaches with Node.js and Python examples: HMAC-SHA256 with the crypto module, JWS with the jose library, and JCS + ECDSA for payload-stable signatures. It also covers real-world webhook patterns from GitHub, Stripe, and Shopify, and the pitfalls that cause verification to silently fail.
Key Terms
- HMAC (Hash-based Message Authentication Code)
- A symmetric MAC that combines a cryptographic hash function (e.g., SHA-256) with a shared secret key. Both signing and verification require the same secret; HMAC proves message integrity and authenticates the sender within a shared-secret boundary.
- JWS (JSON Web Signature)
- An IETF standard (RFC 7515) that defines how to represent content secured with a digital signature or MAC using JSON-based data structures, in either compact (
header.payload.signature) or JSON serialization. - JCS (JSON Canonicalization Scheme)
- An IETF standard (RFC 8785) that specifies a deterministic serialization of JSON: object keys sorted by Unicode code point, no insignificant whitespace, IEEE 754 number serialization. Two compliant implementations produce bit-identical output for the same logical JSON value.
- Signature
- A cryptographic value computed over a message using a key. A valid signature proves the message has not been altered and was produced by a party holding the key. Symmetric signatures (HMAC) use the same key for sign and verify; asymmetric signatures (ECDSA, EdDSA, RSA) use a private key to sign and a public key to verify.
- Canonical form
- A unique, standardized byte representation of a value. Two logically equivalent JSON objects (same keys and values, different ordering) have different byte representations — canonicalization converts both to the same unique form before signing.
- Replay attack
- An attack where a valid signed message captured from a previous interaction is re-submitted to the server. Defenses include expiry timestamps, per-request nonces, and server-side nonce tracking within the validity window.
- ECDSA (Elliptic Curve Digital Signature Algorithm)
- An asymmetric signature algorithm using elliptic curve key pairs. ECDSA P-256 (ES256 in JWS) produces compact 64-byte signatures and provides 128-bit security. Unlike HMAC, the public key can be distributed openly, allowing any party to verify without holding the signing secret.
Why Sign JSON: Integrity vs Authentication
Signing JSON serves two distinct security goals. Integrity means the receiver can detect any modification to the payload after it was signed — a single byte change invalidates the signature. Authentication means the receiver can verify the payload came from a specific party holding the signing key, not an impersonator. HMAC provides both within a shared-secret boundary; asymmetric signatures provide both with the added property that anyone holding the public key can verify without learning the signing secret.
Common use cases for signed JSON:
- Webhooks — GitHub, Stripe, and Shopify all sign webhook payloads with HMAC-SHA256 so your endpoint can reject forged requests.
- API payloads — Request signing proves the payload was not tampered with in transit, complementing TLS which only protects the channel.
- Config files — Signing a configuration file distributed to multiple services lets each service verify it was not modified after leaving the origin.
- JWT / access tokens — JWS compact serialization is the foundation of JWT; the signature proves the claims were issued by the expected authority.
- Audit trails — Signing log entries or records at creation time lets you prove they were not altered after the fact.
The right approach depends on your trust model. If both sides share a secret (same organization, two services), HMAC-SHA256 is simple and fast. If the verifier is external or must not learn the signing key, use asymmetric signatures (JWS with ES256 or EdDSA). If you need payload stability across different serializers, add JCS canonicalization as a pre-processing step before either.
HMAC-SHA256 in Node.js and Python
HMAC-SHA256 is the fastest way to add a tamper-proof signature to a JSON payload when both signer and verifier share a secret. The critical constraint: the JSON must be serialized to the same bytes on both sides. Always serialize to a canonical form before computing the HMAC.
Node.js — crypto module
import { createHmac, timingSafeEqual } from 'crypto'
const SECRET = process.env.SIGNING_SECRET! // >= 32 random bytes
// --- Sign ---
function signJson(payload: object): string {
// Canonical serialization: sort keys, no extra whitespace
const canonical = JSON.stringify(payload, Object.keys(payload).sort())
const sig = createHmac('sha256', SECRET)
.update(canonical)
.digest('base64url')
return sig
}
// --- Verify ---
function verifyJson(payload: object, receivedSig: string): boolean {
const canonical = JSON.stringify(payload, Object.keys(payload).sort())
const expected = createHmac('sha256', SECRET)
.update(canonical)
.digest('base64url')
// Timing-safe comparison prevents timing attacks
const a = Buffer.from(expected)
const b = Buffer.from(receivedSig)
if (a.length !== b.length) return false
return timingSafeEqual(a, b)
}
// Usage
const payload = { userId: 'u_123', action: 'purchase', amount: 49.99 }
const sig = signJson(payload)
console.log('Signature:', sig)
console.log('Valid:', verifyJson(payload, sig)) // trueNode.js — Web Crypto API (Node.js 18+, Deno, Bun, browser)
const encoder = new TextEncoder()
async function importHmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign', 'verify']
)
}
async function signJsonWebCrypto(payload: object, secret: string): Promise<string> {
const key = await importHmacKey(secret)
const canonical = JSON.stringify(payload, Object.keys(payload).sort())
const sigBuffer = await crypto.subtle.sign('HMAC', key, encoder.encode(canonical))
// Convert ArrayBuffer to base64url
return btoa(String.fromCharCode(...new Uint8Array(sigBuffer)))
.replace(/+/g, '-').replace(///g, '_').replace(/=/g, '')
}
async function verifyJsonWebCrypto(
payload: object, sig: string, secret: string
): Promise<boolean> {
const key = await importHmacKey(secret)
const canonical = JSON.stringify(payload, Object.keys(payload).sort())
const sigBytes = Uint8Array.from(
atob(sig.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)
)
return crypto.subtle.verify('HMAC', key, sigBytes, encoder.encode(canonical))
}Python — hmac module
import hmac
import hashlib
import json
import base64
import os
SECRET = os.environ['SIGNING_SECRET'].encode()
def sign_json(payload: dict) -> str:
# Sort keys for stable serialization
canonical = json.dumps(payload, sort_keys=True, separators=(',', ':'))
sig = hmac.new(SECRET, canonical.encode(), hashlib.sha256).digest()
return base64.urlsafe_b64encode(sig).rstrip(b'=').decode()
def verify_json(payload: dict, received_sig: str) -> bool:
expected_sig = sign_json(payload)
# hmac.compare_digest is timing-safe
return hmac.compare_digest(expected_sig, received_sig)
payload = {'userId': 'u_123', 'action': 'purchase', 'amount': 49.99}
sig = sign_json(payload)
print('Signature:', sig)
print('Valid:', verify_json(payload, sig)) # TrueJSON Canonicalization (JCS, RFC 8785)
JCS defines a byte-for-byte deterministic serialization of JSON. Any two JCS-compliant implementations produce identical output for the same logical JSON value, regardless of the library, language, or original key ordering used to construct the object. This makes JCS the correct pre-processing step before signing JSON that may be re-serialized by different systems.
The JCS rules:
- Object keys are sorted by Unicode code point (lexicographic on UTF-16 code units, matching JavaScript's string comparison).
- No insignificant whitespace — no spaces, tabs, or newlines outside string values.
- Numbers follow IEEE 754 double-precision serialization: integers up to 2^53 are represented without decimal point; floating-point numbers use the shortest round-trip representation.
- Strings use standard JSON escape sequences; no unnecessary escaping.
- Sorting is recursive — nested objects also have their keys sorted.
Node.js — manual JCS implementation
// Minimal JCS-compliant canonicalization (covers all JSON value types)
function canonicalize(value: unknown): string {
if (value === null || typeof value !== 'object') {
return JSON.stringify(value) // primitives, strings, booleans, null
}
if (Array.isArray(value)) {
return '[' + value.map(canonicalize).join(',') + ']'
}
// Object: sort keys by Unicode code point
const sortedKeys = Object.keys(value as object).sort()
const pairs = sortedKeys.map(
k => JSON.stringify(k) + ':' + canonicalize((value as Record<string, unknown>)[k])
)
return '{' + pairs.join(',') + '}'
}
// Example
const a = { z: 1, a: 2, m: { y: 3, b: 4 } }
const b = { a: 2, m: { b: 4, y: 3 }, z: 1 } // different ordering
console.log(canonicalize(a)) // {"a":2,"m":{"b":4,"y":3},"z":1}
console.log(canonicalize(b)) // {"a":2,"m":{"b":4,"y":3},"z":1} identical!
console.log(canonicalize(a) === canonicalize(b)) // truePython — json-canonicalize library
# pip install json-canonicalize
from json_canonicalize import canonicalize
a = {'z': 1, 'a': 2, 'm': {'y': 3, 'b': 4}}
b = {'a': 2, 'm': {'b': 4, 'y': 3}, 'z': 1}
print(canonicalize(a)) # {"a":2,"m":{"b":4,"y":3},"z":1}
print(canonicalize(a) == canonicalize(b)) # TrueSign Canonicalized JSON with ECDSA P-256 in Node.js
Combining JCS canonicalization with ECDSA P-256 (ES256) produces a signature that is stable across any serializer and verifiable by anyone holding the public key. This is the recommended pattern for signing JSON payloads that must be verified by external parties or passed through systems that may re-serialize.
import { generateKeyPair, sign, verify, KeyObject } from 'crypto'
import { promisify } from 'util'
const generateKeyPairAsync = promisify(generateKeyPair)
// Generate ECDSA P-256 key pair (do once, then persist as PEM)
const { privateKey, publicKey } = await generateKeyPairAsync('ec', {
namedCurve: 'P-256',
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
})
// Minimal JCS canonicalization (same as above)
function canonicalize(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value)
if (Array.isArray(value)) return '[' + value.map(canonicalize).join(',') + ']'
const sorted = Object.keys(value as object).sort()
return '{' + sorted.map(k =>
JSON.stringify(k) + ':' + canonicalize((value as Record<string, unknown>)[k])
).join(',') + '}'
}
// Sign: JCS canonicalize, then ECDSA P-256 SHA-256
function signWithEcdsa(payload: object, privKey: string): string {
const canonical = canonicalize(payload)
const sigBuffer = sign('SHA256', Buffer.from(canonical), privKey)
return sigBuffer.toString('base64url')
}
// Verify: same canonicalization, then verify signature
function verifyWithEcdsa(payload: object, sig: string, pubKey: string): boolean {
const canonical = canonicalize(payload)
const sigBuffer = Buffer.from(sig, 'base64url')
return verify('SHA256', Buffer.from(canonical), pubKey, sigBuffer)
}
const payload = { event: 'order.created', orderId: 'ord_42', amount: 99.00 }
const sig = signWithEcdsa(payload, privateKey)
console.log('Signature:', sig)
console.log('Valid:', verifyWithEcdsa(payload, sig, publicKey)) // true
// Tamper detection
const tampered = { ...payload, amount: 0 }
console.log('Tampered valid:', verifyWithEcdsa(tampered, sig, publicKey)) // falseThe signature is computed over the canonical UTF-8 bytes of the JSON. Because JCS is deterministic, any party can independently canonicalize the same payload and verify the signature — even if they received the JSON in a different serialization order. Export and distribute the public key as PEM or in JWK format via a JWKS endpoint.
JWS Compact Serialization: HS256 and RS256
JSON Web Signature (JWS) compact serialization is three Base64URL-encoded strings joined by dots: header.payload.signature. The payload is the Base64URL encoding of the raw JSON bytes — because Base64URL is deterministic, the signature is stable regardless of JSON formatting on the receiving end. This is how JWT works under the hood.
The jose library is the reference JavaScript/TypeScript implementation supporting all standard JWS algorithms in Node.js and browsers.
HS256 (HMAC-SHA256 via JWS)
import { CompactSign, compactVerify } from 'jose'
const secret = new TextEncoder().encode(process.env.JWS_SECRET!)
const payload = new TextEncoder().encode(
JSON.stringify({ event: 'user.created', userId: 'u_123' })
)
// Sign — produces header.payload.signature
const jws = await new CompactSign(payload)
.setProtectedHeader({ alg: 'HS256' })
.sign(secret)
console.log(jws)
// eyJhbGciOiJIUzI1NiJ9.eyJldmVudCI6InVzZXIuY3JlYXRlZCIs...
// Verify — always pass algorithm explicitly
const { payload: rawBytes, protectedHeader } = await compactVerify(jws, secret, {
algorithms: ['HS256'], // never allow token header to dictate algorithm
})
const data = JSON.parse(new TextDecoder().decode(rawBytes))
console.log(data.event) // "user.created"
console.log(protectedHeader.alg) // "HS256"RS256 (RSA asymmetric — widely compatible)
import { CompactSign, compactVerify, importPKCS8, importSPKI } from 'jose'
import { readFileSync } from 'fs'
const privateKey = await importPKCS8(readFileSync('./private.pem', 'utf-8'), 'RS256')
const publicKey = await importSPKI(readFileSync('./public.pem', 'utf-8'), 'RS256')
const payload = new TextEncoder().encode(
JSON.stringify({ event: 'payment.captured', amount: 49.99 })
)
const jws = await new CompactSign(payload)
.setProtectedHeader({ alg: 'RS256', kid: 'rsa-2026-01' })
.sign(privateKey)
// Any party with publicKey can verify — no secret needed
const { payload: raw } = await compactVerify(jws, publicKey, {
algorithms: ['RS256'],
})
console.log(JSON.parse(new TextDecoder().decode(raw)))For new systems, prefer ES256 (ECDSA P-256) or EdDSA (Ed25519) over RS256 — they produce compact 64-byte signatures versus 256 bytes for RSA-2048, and EdDSA eliminates the nonce-reuse vulnerability in ECDSA implementations. See the full JWS guide for EdDSA examples and JWKS endpoint setup.
Webhook Signature Verification: GitHub, Stripe, Shopify
Webhook providers sign their payloads with HMAC-SHA256 using a shared secret you configure when creating the webhook. The critical rule: verify signatures over the raw request body bytes, not over a re-serialized parsed object. Parsing and re-serializing JSON can change whitespace or key ordering, invalidating a valid signature.
GitHub Webhooks (X-Hub-Signature-256)
import { createHmac, timingSafeEqual } from 'crypto'
import type { IncomingMessage, ServerResponse } from 'http'
const GITHUB_SECRET = process.env.GITHUB_WEBHOOK_SECRET!
export function verifyGithubWebhook(req: IncomingMessage, rawBody: Buffer): boolean {
const receivedSig = req.headers['x-hub-signature-256'] as string
if (!receivedSig || !receivedSig.startsWith('sha256=')) return false
const expected = 'sha256=' + createHmac('sha256', GITHUB_SECRET)
.update(rawBody)
.digest('hex') // GitHub uses hex, not base64url
const a = Buffer.from(expected)
const b = Buffer.from(receivedSig)
// Lengths must match before timingSafeEqual
return a.length === b.length && timingSafeEqual(a, b)
}
// Express middleware — read raw body BEFORE json() middleware
app.post('/webhook/github', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyGithubWebhook(req, req.body)) {
return res.status(401).json({ error: 'Invalid signature' })
}
const event = JSON.parse(req.body.toString())
// handle event...
res.status(200).send('ok')
})Stripe Webhooks (Stripe-Signature + timestamp)
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!
app.post('/webhook/stripe', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['stripe-signature'] as string
let event: Stripe.Event
try {
// Stripe's SDK verifies the HMAC and checks the timestamp (replay protection)
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret)
} catch (err) {
return res.status(400).json({ error: 'Webhook signature verification failed' })
}
// Handle verified event
switch (event.type) {
case 'payment_intent.succeeded':
console.log('Payment captured:', event.data.object.id)
break
}
res.status(200).send('ok')
})Stripe's Stripe-Signature header includes a timestamp (t=) and one or more HMAC values (v1=). The SDK constructs the signed payload as timestamp.rawBody, then verifies the HMAC and rejects messages with timestamps older than 300 seconds — providing built-in replay protection.
Shopify Webhooks (X-Shopify-Hmac-Sha256)
import { createHmac, timingSafeEqual } from 'crypto'
const SHOPIFY_SECRET = process.env.SHOPIFY_WEBHOOK_SECRET!
export function verifyShopifyWebhook(rawBody: Buffer, receivedHmac: string): boolean {
// Shopify signs the raw body and base64-encodes (NOT base64url) the result
const expected = createHmac('sha256', SHOPIFY_SECRET)
.update(rawBody)
.digest('base64') // standard base64, not base64url
return timingSafeEqual(Buffer.from(expected), Buffer.from(receivedHmac))
}
app.post('/webhook/shopify', express.raw({ type: 'application/json' }), (req, res) => {
const hmac = req.headers['x-shopify-hmac-sha256'] as string
if (!verifyShopifyWebhook(req.body, hmac)) {
return res.status(401).json({ error: 'Invalid HMAC' })
}
const order = JSON.parse(req.body.toString())
res.status(200).send('ok')
})Common Pitfalls in JSON Signing
Most JSON signature failures fall into a small number of recurring patterns. Understanding them upfront prevents hours of debugging.
Signing Raw JSON Without Canonicalization
The most common mistake. JSON.stringify({ b: 2, a: 1 }) produces {"b":2,"a":1} in JavaScript, but a Go or Python serializer may produce {"a":1,"b":2}. If you sign in JavaScript and verify in Python without agreeing on key ordering, verification fails. Fix: always apply JCS canonicalization before signing and before verifying.
Re-parsing the Body Before Webhook Verification
// WRONG — body parser has already parsed and discarded the raw bytes
app.use(express.json()) // this middleware runs first
app.post('/webhook', (req, res) => {
// req.body is now a parsed object; original bytes are gone
// Re-serializing: JSON.stringify(req.body) may differ from original bytes
const sig = createHmac('sha256', secret).update(JSON.stringify(req.body)).digest('hex')
// ^ This will fail if the provider's bytes differed in any way
})
// CORRECT — use express.raw() for webhook routes BEFORE express.json()
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
// req.body is a Buffer of the original raw bytes
const sig = createHmac('sha256', secret).update(req.body).digest('hex')
// compare to header...
const event = JSON.parse(req.body.toString()) // parse only after verification
})Using === for Signature Comparison (Timing Attack)
// VULNERABLE — string comparison short-circuits on first mismatch
// An attacker can measure response times to learn the expected signature byte-by-byte
if (expected === received) { /* ... */ }
// SAFE — timingSafeEqual takes constant time regardless of where strings differ
import { timingSafeEqual } from 'crypto'
const a = Buffer.from(expected)
const b = Buffer.from(received)
const valid = a.length === b.length && timingSafeEqual(a, b)Forgetting Replay Attack Protection
A valid signature only proves the message was authentic when it was signed — not that it is fresh. Include a timestamp in the signed payload and reject messages with timestamps outside a 5-minute window. For higher-security scenarios, also include a UUID nonce and track seen nonces server-side. Stripe's SDK does this automatically; for custom systems you must implement it explicitly.
Weak Secrets and Key Rotation Gaps
- HMAC-SHA256 secrets must be at least 256 bits (32 random bytes). Short or low-entropy secrets are vulnerable to brute force.
- Rotate secrets periodically. During rotation, accept both old and new secrets for a transition window, then drop the old one.
- For JWS with asymmetric keys, publish public keys via a JSON Web Key (JWK) endpoint and use
kidheaders to identify which key was used — this enables zero-downtime key rotation. - Never log or expose secrets in error messages, stack traces, or HTTP responses.
Using alg:none in JWS (Critical Security Flaw)
Never accept tokens with "alg":"none" in production. RFC 7515 defines it as a valid "unsecured JWS" with an empty signature, but accepting it allows attackers to forge arbitrary payloads with no key. Always pass an explicit algorithms allowlist to your verification function and reject any algorithm not in the list. See JWT best practices for a full security checklist.
FAQ
How do I sign JSON data with HMAC-SHA256 in JavaScript?
Use the Node.js crypto module: createHmac('sha256', secret).update(canonical).digest('base64url'), where canonical is the JCS-sorted JSON string. For browser or edge runtimes, use the Web Crypto API: crypto.subtle.importKey with { name: "HMAC", hash: "SHA-256" }, then crypto.subtle.sign('HMAC', key, data). Always canonicalize the JSON first — sort keys recursively and remove whitespace — to ensure the signature is stable across serializers.
What is the difference between HMAC and a digital signature for JSON?
HMAC is symmetric: the same secret key is used to both create and verify the MAC. It requires all parties to share the secret, limiting its use to trusted internal boundaries (e.g., two services you control). A digital signature (ECDSA, EdDSA, RSA) is asymmetric: a private key signs, and any party holding the public key can verify — without learning the private key. Use HMAC for webhook verification between your own services; use digital signatures when external parties must verify without accessing your signing secret.
Why does JSON signature verification fail when I reorder keys?
A cryptographic signature is computed over exact bytes. {"b":2,"a":1} and {"a":1,"b":2} are semantically identical JSON but byte-for-byte different strings — signing one and verifying the other always fails. Different JSON serializers (JavaScript, Python, Go) do not guarantee key ordering. The fix is to apply JCS canonicalization (RFC 8785) before signing: sort all object keys lexicographically at every nesting level, remove all insignificant whitespace. Both signer and verifier must apply the same canonicalization.
What is JSON Canonicalization (JCS) and why do I need it?
JCS (RFC 8785) defines a deterministic serialization of JSON: object keys sorted by Unicode code point, no insignificant whitespace, IEEE 754 number normalization. Any two JCS-compliant implementations produce bit-identical output for the same logical JSON value. You need JCS when you sign JSON that may be re-serialized by a different library or language — without it, verification is fragile and will fail whenever key order or whitespace differs between signer and verifier.
How do I verify a JSON Web Signature (JWS)?
With the jose library: const { payload } = await compactVerify(jws, key, { algorithms: ["ES256"] }). Always pass an explicit algorithms array — never derive it from the token header. The returned payload is the raw bytes of the signed content; decode and parse it with JSON.parse(new TextDecoder().decode(payload)). For HMAC-based JWS (HS256), pass the same TextEncoder-encoded secret used for signing.
How does GitHub verify webhook signatures?
GitHub sends a X-Hub-Signature-256 header with value sha256=<hex>. To verify: compute HMAC-SHA256 over the raw request body bytes (not parsed JSON) using your webhook secret, hex-encode the result, prepend sha256=, and compare to the header using timingSafeEqual. Never use === for the comparison — it is vulnerable to timing attacks. Process the body with express.raw() or equivalent to access the original bytes before any JSON parsing.
Can I use the Web Crypto API to sign JSON in the browser?
Yes. crypto.subtle is available in all modern browsers, Node.js 18+, Deno, and Bun. For HMAC-SHA256: import a key with { name: "HMAC", hash: "SHA-256" } and call crypto.subtle.sign('HMAC', key, data). For ECDSA P-256: generate a key pair with generateKey and sign with crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, privateKey, data). Always canonicalize the JSON to a UTF-8 string and encode it with TextEncoder before passing to the sign function.
How do I prevent replay attacks on signed JSON?
Include a Unix timestamp (iat) in the signed payload and reject messages with timestamps outside a 5-minute window. For higher security, also include a UUID nonce per request and track seen nonces server-side for the validity window — this prevents an attacker from submitting the same valid signed message twice within the time window. For JWS, set a short exp claim. Stripe does this automatically by including a timestamp in the Stripe-Signature header; for custom systems you must implement timestamp and nonce validation yourself.
Further reading and primary sources
- RFC 8785 — JSON Canonicalization Scheme (JCS) — Official JCS specification for deterministic JSON serialization
- RFC 7515 — JSON Web Signature (JWS) — Official JWS specification
- jose npm library — Universal JavaScript JWS/JWT/JWE implementation
- Node.js crypto.timingSafeEqual — Timing-safe HMAC comparison for webhook verification
- Web Crypto API (MDN) — Browser-native cryptographic primitives including HMAC and ECDSA