Encited logo
Pricing

Verifying response signatures

Verify the RFC 9421 HMAC signatures Encited attaches to rendered HTML — what gets signed, how to choose a freshness window, a copy-paste reference verifier, and troubleshooting.

Response signing is available on the Enterprise plan.

What this is for

When you serve Encited-rendered HTML from your own infrastructure — your origin, a CDN, an edge worker — response signing lets that infrastructure cryptographically confirm the HTML really came from Encited and wasn't altered in transit. Each rendered response carries an RFC 9421 HMAC-SHA256 signature over a few key parts of the message; you verify it with a per-domain secret only you and Encited hold.

Before you start

  1. In your dashboard, open the domain's settings → Cache & routingResponse signing, and turn it on.
  2. Copy two values from that panel:
    • the signing secret (keep it private — it's the HMAC key),
    • the key ID (ks_…, a public identifier for the current key).
  3. Configure your verifier with the secret. You're ready to verify.

What gets signed

Encited signs the rendered HTML it serves, however you receive it:

  • HTML served to crawlers that hit your domain through the Encited proxy, and
  • HTML you fetch yourself from the GET /api/prerender/render API.

Anything that isn't an Encited-rendered HTML body is not signed. In particular, when the render API has nothing to render (a static asset, a scanner probe, or a pass-through), it returns a 304 with a Location header pointing back at the URL you requested and no body — there's nothing to sign. Treat any response without signature headers as an unsigned pass-through (see Important limits).

Every signed response carries three headers:

Header Value Purpose
Content-Digest sha-256=:<base64>: SHA-256 digest of the response body (RFC 9530).
Signature-Input sig1=(…);created=…;keyid="…";alg="hmac-sha256" Which components are covered, and the signature parameters.
Signature sig1=:<base64>: The HMAC over the signature base.

The signature covers these components, in this exact order:

  1. @authority — the request host, lowercased (no port unless non-default).
  2. @path — the request path without the query string.
  3. content-digest — the Content-Digest header value above.
  4. @signature-params — always last; the serialized parameter set (the part after sig1= in Signature-Input).

The signature base

The string that gets HMAC'd is the signature base: one line per covered component as "<name>": <value>, joined with \n (LF), with @signature-params last. For a request to https://example.com/blog/post:

text
CopyDownload
"@authority": example.com
"@path": /blog/post
"content-digest": sha-256=:<base64-digest>:
"@signature-params": ("@authority" "@path" "content-digest");created=1750000000;keyid="ks_abc123";alg="hmac-sha256"

You don't reconstruct the @signature-params value by hand — copy it verbatim from the Signature-Input header. That way created, keyid, alg, and the component list always match exactly what Encited signed.

Verifying a response

The steps:

  1. Read Content-Digest, Signature-Input, and Signature. No signature headers → it's an unsigned pass-through; allow it (don't fail closed).
  2. Recompute the SHA-256 of the body and confirm it matches Content-Digest.
  3. Check the created timestamp against your freshness window.
  4. Rebuild the signature base and verify the Signature HMAC with your secret.

Reference verifier

Dependency-free; runs anywhere with Web Crypto (browsers, Cloudflare Workers, modern Node). headers is an object with lowercased keys (e.g. Object.fromEntries(response.headers)), and bodyText is the response body as text (the decoded HTML — not compressed bytes).

js
CopyDownload
// Verify an Encited response signature (RFC 9421 + RFC 9530, HMAC-SHA256).
// Returns true only for a valid signature. A response with no signature headers
// returns false — that's an unsigned pass-through, which you should ALLOW.
async function verify({ url, headers, bodyText, secret, windowSeconds = 360 }) {
const sigInput = headers["signature-input"];
const sig = headers["signature"];
const cdHeader = headers["content-digest"];
if (!sigInput || !sig || !cdHeader) return false; // unsigned — treat as pass-through
const enc = new TextEncoder();
const u = new URL(url);
// 1. Recompute Content-Digest over the received body and match the header.
const digest = await crypto.subtle.digest("SHA-256", enc.encode(bodyText));
const contentDigest = `sha-256=:${btoa(String.fromCharCode(...new Uint8Array(digest)))}:`;
if (cdHeader !== contentDigest) return false;
// 2. Check freshness using the `created` parameter.
const created = Number(/created=(\d+)/.exec(sigInput)?.[1]);
if (!created || Math.abs(Date.now() / 1000 - created) > windowSeconds)
return false;
// 3. Rebuild the exact signature base (copy the params verbatim from the header).
const params = sigInput.replace(/^[^=]+=/, "");
const base = [
`"@authority": ${u.host.toLowerCase()}`,
`"@path": ${u.pathname}`,
`"content-digest": ${contentDigest}`,
`"@signature-params": ${params}`,
].join("\n");
// 4. Verify the HMAC.
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"],
);
const sigB64 = /:([^:]*):/.exec(sig.slice(sig.indexOf("=") + 1))?.[1];
const sigBytes = Uint8Array.from(atob(sigB64), (ch) => ch.charCodeAt(0));
return crypto.subtle.verify("HMAC", key, sigBytes, enc.encode(base));
}

Putting it together

js
CopyDownload
const pageUrl = "https://example.com/blog/post";
const res = await fetch(
`https://encited.com/api/prerender/render?url=${encodeURIComponent(pageUrl)}`,
{
headers: {
/* your Encited API key */
},
},
);
const headers = Object.fromEntries(res.headers); // lowercased keys
const bodyText = await res.text();
if (!headers["signature"]) {
// Unsigned pass-through (304, asset, or signing off) — serve as-is.
} else if (
await verify({ url: pageUrl, headers, bodyText, secret: SIGNING_SECRET })
) {
// Authentic Encited render — safe to serve or cache.
} else {
// Signature present but invalid — reject or refetch.
}

Verifying at your edge/proxy instead of via the API works the same way — use the incoming request's URL as url and the response you received from Encited.

Choosing a freshness window

created is the moment Encited signed the response. How old it can be by the time you verify depends entirely on whether the response was cached on the way to you:

  • Fetching each render fresh (e.g. calling /api/prerender/render directly, no cache in between): created is only seconds old. A small window — a few minutes — is plenty and gives you real replay protection.
  • Signed responses pass through a cache (your CDN, a shared proxy): a cache can keep serving the same signed response — same created — for as long as it's allowed to. Encited marks rendered HTML cacheable for your domain's cache-refresh interval via Cache-Control: max-age=<ttl>. Your window must cover the longest a response is actually cached before it reaches your verifier, plus clock skew — otherwise you'll reject responses your own cache is still serving.

Pick the window for how you consume responses, and check the max-age on the responses you receive if you're unsure. (Encited's internal edge cache is re-signed on every request, so it never makes created stale — only caches downstream of Encited matter here.)

Rotating keys

Rotating generates a new secret and a new keyid, and the old secret stops signing immediately. Because each response advertises its keyid, you can roll over without downtime:

  1. Before rotating, note your current keyid and secret.
  2. Rotate in the dashboard and copy the new secret.
  3. In your verifier, keep both entries in a keyid → secret map for at least one cache-TTL (responses signed with the old secret may still be cached downstream).
  4. After that window, drop the old entry.

To use the map, read the keyid parameter out of Signature-Input and pick the matching secret before verifying. Rotate during low traffic and update your verifier promptly.

Important limits

Only Encited-rendered responses are signed — do not fail closed globally. Origin pass-throughs (the human SPA, on-demand-disabled cache misses, render-limit fallbacks, origin errors) are returned unsigned. A verifier must allow unsigned responses through, or it will break human traffic. The signature is detective, not preventive: it proves provenance and catches tampering when the headers are present, but it does not stop an attacker who strips the headers. The guarantee is "this signed response is authentic," not "all proxied traffic is authenticated."

Troubleshooting

A valid-looking signature won't verify? Work down this list:

  • Wrong body. Hash the decoded HTML text, exactly as received. If you read raw compressed bytes (gzip/brotli) instead of the decompressed body, the digest won't match.
  • Reconstructed params instead of copying them. Always take the @signature-params value verbatim from Signature-Input — don't rebuild the parameter string yourself.
  • @authority / @path mismatch. Use the request host (lowercased) and the path without the query string. Don't normalize away www, ports, or trailing slashes.
  • Window too short. If responses are cached on the way to you, a short window rejects legitimately-cached responses — see Choosing a freshness window.
  • Wrong key after a rotation. Match the response's keyid to the right secret.
  • Treating unsigned as invalid. A response with no signature headers is a pass-through — allow it, don't reject it.

Using a standard library instead

You don't have to hand-roll verification. Any spec-compliant RFC 9421 implementation works — for example http-message-signatures — as long as you configure it for HMAC-SHA256, cover @authority, @path, and content-digest, and apply the same freshness window and unsigned-pass-through handling described above.

Avatar
How can we help?
Get instant answers to your questions or leave a message for an engineer will reach out
Ask AI about Encited
See our docs
Contact support
Leave a message
We'll get back to you soon
Avatar
Ask AI about Encited
Team is also here to help
Thinking
Preview
Drop an image to attach
Powered by ReplyMaven