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
- In your dashboard, open the domain's settings → Cache & routing → Response signing, and turn it on.
- 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).
- 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/renderAPI.
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:
@authority— the request host, lowercased (no port unless non-default).@path— the request path without the query string.content-digest— theContent-Digestheader value above.@signature-params— always last; the serialized parameter set (the part aftersig1=inSignature-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:
"@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:
- Read
Content-Digest,Signature-Input, andSignature. No signature headers → it's an unsigned pass-through; allow it (don't fail closed). - Recompute the SHA-256 of the body and confirm it matches
Content-Digest. - Check the
createdtimestamp against your freshness window. - Rebuild the signature base and verify the
SignatureHMAC 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).
// 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-throughconst 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
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 keysconst 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/renderdirectly, no cache in between):createdis 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 viaCache-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:
- Before rotating, note your current
keyidand secret. - Rotate in the dashboard and copy the new secret.
- In your verifier, keep both entries in a
keyid → secretmap for at least one cache-TTL (responses signed with the old secret may still be cached downstream). - 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-paramsvalue verbatim fromSignature-Input— don't rebuild the parameter string yourself. @authority/@pathmismatch. Use the request host (lowercased) and the path without the query string. Don't normalize awaywww, 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
keyidto 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.
Related
- On-demand render — controls which cache misses get rendered (and therefore signed).
- Cache refresh rules — set the cache interval that drives your freshness window.
