Verify a consent receipt
Independently verify the Ed25519 signature on any receipt, with openssl or Node.
What you need
- The site's
publicKey, the value ofdata-site-idon the banner snippet. - The receipt id, the
idfield in the dashboard consent log or CSV export. - Network access to
https://api.cookielint.com. Both endpoints are unauthenticated.
Fetch the public key
The signing public key registry plus its canonical-payload spec lives at a stable URL:
# Fetch periodically and mirror; alert on changes.
curl https://api.cookielint.com/api/v1/sites/consent-receipt-key.jsonResponse:
{
"alg": "Ed25519",
"use": "consent-receipt-signing",
"canonicalEncoding": "json-fixed-field-order",
"signatureEncoding": "base64",
"activeKid": "v1",
"keys": [
{
"kid": "v1", "status": "active",
"publicKeySpkiB64": "MCowBQYDK2VwAyEA...",
"canonicalFields": ["siteId","version","...","keyId"]
}
],
"publicKeySpkiB64": "MCowBQYDK2VwAyEA...", // active key, backwards compat
"verification": {
"canonicalFields": ["...no keyId..."], // legacy receipts
"canonicalEncoding": "json-fixed-field-order",
"signatureEncoding": "base64"
}
}publicKeySpkiB64is the SPKI DER of the Ed25519 public key, base64-encoded. The field order in each entry's canonicalFields is binding: the signed payload is the JSON of an object with those keys, in that order, with no extra whitespace. Each receipt names the kid it was signed with, so verifiers pick the matching entry from keys.publicKeySpkiB64 and verification fields are kept for verifiers that targeted the original single-key shape. New verifiers should read keys and look up by kid.After a key rotation, keys grows: the old entry switches to "status": "retired", a new entry with "status": "active" appears, and activeKid points at the new one. The retired entry stays in the doc so verifiers can still check receipts signed before the rotation. Write your verifier to iterate keys and match by kid from day one and you will not need to update it when CookieLint rotates.
Fetch the receipt
curl https://api.cookielint.com/api/v1/sites/$PUBLIC_KEY/consent/$RECEIPT_IDResponse contains canonical (the exact fields that were signed), signature (base64), signatureAlg, signingKeyId (the kid of the key that signed it, or null for legacy receipts), and publicKeySpkiB64 already resolved to the correct key for this receipt.
signingKeyId is non-null, canonical includes a keyId field at the end. When it is null, canonical uses the legacy field set (no keyId). Reconstruct the signed bytes accordingly.signatureAlg is HMAC_SHA256, the receipt predates the Ed25519 rollout for this environment and cannot be verified externally. Newer receipts carry ED25519.Verify with openssl
Reconstruct the canonical JSON in the exact field order, decode the SPKI public key, decode the signature, then call openssl pkeyutl -verify:
# receipt.json is the full GET response from the previous step.
# Build the canonical payload: 15 fixed fields, with keyId appended
# only when signingKeyId is non-null on the receipt.
if [ "$(jq -r .signingKeyId receipt.json)" = "null" ]; then
jq -c '{
siteId: .canonical.siteId, version: .canonical.version,
regime: .canonical.regime, locale: .canonical.locale,
decisions: .canonical.decisions,
tcfString: .canonical.tcfString, gppString: .canonical.gppString,
capturedAt: .canonical.capturedAt,
subjectHash: .canonical.subjectHash,
pseudonymVersion: .canonical.pseudonymVersion,
origin: .canonical.origin, pathname: .canonical.pathname,
source: .canonical.source, variant: .canonical.variant,
receiptNonce: .canonical.receiptNonce
}' receipt.json > payload.json
else
jq -c '{
siteId: .canonical.siteId, version: .canonical.version,
regime: .canonical.regime, locale: .canonical.locale,
decisions: .canonical.decisions,
tcfString: .canonical.tcfString, gppString: .canonical.gppString,
capturedAt: .canonical.capturedAt,
subjectHash: .canonical.subjectHash,
pseudonymVersion: .canonical.pseudonymVersion,
origin: .canonical.origin, pathname: .canonical.pathname,
source: .canonical.source, variant: .canonical.variant,
receiptNonce: .canonical.receiptNonce, keyId: .canonical.keyId
}' receipt.json > payload.json
fi
jq -r '.publicKeySpkiB64' receipt.json | base64 -d > pubkey.der
jq -r '.signature' receipt.json | base64 -d > sig.bin
openssl pkeyutl -verify -pubin -inkey pubkey.der -keyform DER \
-rawin -in payload.json -sigfile sig.bin
# Signature Verified Successfullyjq -c emits the object on one line with no extra whitespace, with keys in the order listed. That matters: the signed payload is byte-for-byte that exact JSON. The publicKeySpkiB64 on the receipt response is already the public key for whichever kid signed this receipt, so verifiers do not need to look it up in the keys registry manually.
Verify with Node
One file, zero dependencies beyond Node 22:
// node verify.mjs receipt.json
import { readFileSync } from 'node:fs';
import { createPublicKey, verify } from 'node:crypto';
const BASE_FIELDS = [
'siteId', 'version', 'regime', 'locale', 'decisions',
'tcfString', 'gppString', 'capturedAt', 'subjectHash',
'pseudonymVersion', 'origin', 'pathname', 'source',
'variant', 'receiptNonce',
];
const receipt = JSON.parse(readFileSync(process.argv[2], 'utf8'));
if (receipt.signatureAlg !== 'ED25519') {
console.error('Not an Ed25519 receipt; cannot verify externally');
process.exit(2);
}
// Receipts signed under the versioned-key scheme include a kid in
// signingKeyId and a keyId field at the end of canonical. Legacy
// receipts (pre-versioning) have signingKeyId === null and the
// shorter field list. Build the canonical accordingly.
const fields = receipt.signingKeyId !== null
? [...BASE_FIELDS, 'keyId']
: BASE_FIELDS;
const ordered = {};
for (const k of fields) ordered[k] = receipt.canonical[k];
const payload = Buffer.from(JSON.stringify(ordered), 'utf8');
// receipt.publicKeySpkiB64 is already the right key for this receipt.
const pubKey = createPublicKey({
key: Buffer.from(receipt.publicKeySpkiB64, 'base64'),
format: 'der',
type: 'spki',
});
const sig = Buffer.from(receipt.signature, 'base64');
const ok = verify(null, payload, pubKey, sig);
console.log(ok ? 'OK: signature verified' : 'FAIL: signature mismatch');
process.exit(ok ? 0 : 1);kid: fetch consent-receipt-key.json, look up receipt.signingKeyId in the keysarray, and verify against that entry's publicKeySpkiB64. This is what you do when you want to verify offline against a snapshot of the key registry rather than trusting whatever the receipt endpoint returns each time.What this proves, what it does not
- A successful verify proves the canonical payload was signed by the private key paired with the published SPKI public key, and has not been altered since.
- It does not, on its own, prove the publisher of the key is CookieLint. Pair the public key with the snippet domain and your transport-level trust posture (TLS, DNS, CT logs).
- Verification is offline once you hold the public key. We recommend mirroring it the first time you see it and alerting on changes.

