Verifying signatures
Anyone who learns your webhook URL can send JSON to it. The signature is how you tell a real CredenShare delivery from anything else, so verify it on every request before you act on the body.
Every delivery is signed with HMAC-SHA256 using a secret that belongs to that one endpoint. You are shown the secret once, when you create the endpoint, and once more each time you rotate it. It is never returned by any listing endpoint or tool, so if you lose it, rotate the endpoint to get a new one.
The signature header
Each delivery carries a X-CredenShare-Signature header. Its value is a comma-separated list of key=value pairs:
X-CredenShare-Signature: t=<unix-seconds>,v1=<hex>
| Key | Meaning |
|---|---|
t | The moment this attempt was signed, as decimal Unix seconds. Present exactly once. |
v1 | A signature, as lowercase hexadecimal (64 characters). Present one or more times. |
During a secret rotation the header carries two v1 values — the new secret's signature first, then the previous secret's:
X-CredenShare-Signature: t=<unix-seconds>,v1=<hex-new>,v1=<hex-previous>
v1 value and accept the delivery if any one of them matches. A verifier that reads only the first v1, or only the last one, works most of the time and then fails for a whole day after you rotate a secret.Parse the header by splitting on , and then on the first = of each part, and look values up by name — do not rely on the order or the number of pairs. CredenShare sends no spaces around the separators, but trimming whitespace costs nothing.
What is signed
The signed material is the timestamp, then a single ASCII period, then the raw bytes of the request body:
signed = "<t>" + "." + <raw request body>
signature = hex( HMAC-SHA256(secret, signed) )
The period matters. Without a separator the two values would run together, and different pairs would produce identical signed bytes: a timestamp of 1 with a body of 23, and a timestamp of 12 with a body of 3, would both sign the string 123. A signature captured for one could then be presented as a valid signature for the other, which is exactly the substitution a signature exists to prevent. Use a period — not a colon, and not nothing.
The key is the whole secret
The HMAC key is the full secret string, including its whsec_ prefix, taken as bytes.
whsec_ prefix, do not base64-decode the part after the prefix, and do not hash the secret before using it as the key. Each of those produces a valid-looking HMAC that will never match.A secret looks like whsec_ followed by URL-safe base64 characters. Treat it as an opaque string, store it wherever you keep your other credentials, and pass it to your HMAC function unmodified.
Reject stale deliveries
The replay window is ±5 minutes (300 seconds), measured symmetrically. Reject any delivery whose t differs from your own clock by more than 300 seconds in either direction.
Check the window before you compute any HMAC. It is the cheap check, and it is the part that stops a delivery captured off the wire from being re-posted to your endpoint an hour later.
The timestamp is generated at the moment of each attempt, not when the event happened. A retry of the same delivery therefore arrives with a fresh t and a fresh signature over an unchanged body — so an old timestamp is never a legitimate retry arriving late, and you can keep the window tight without breaking retries.
Use an SDK if you can
All four official SDKs ship a verifier. Each takes the raw body, the header value, and one or more secrets, and each reports failure by raising rather than by returning a falsy value you could forget to check:
import { webhooks } from '@credenshare/sdk'
await webhooks.verify(rawBody, header, [NEW_SECRET, OLD_SECRET])
from credenshare import webhooks
webhooks.verify(raw_body, header, secrets=[NEW_SECRET, OLD_SECRET])
import "github.com/CredenShare/credenshare-sdk-go/webhooks"
err := webhooks.Verify(rawBody, header, []string{newSecret, oldSecret}, nil)
use credenshare::webhooks;
webhooks::verify(raw_body, header, &[&new_secret, &old_secret], &Default::default())?;
One caveat that applies to all four: the blank-secret check trims, but the HMAC is keyed with the untrimmed string. A secret read from a file with a trailing newline verifies against nothing. Trim it yourself.
A correct verifier
The reference implementations below are for anyone not using an SDK.
Node.js
const crypto = require('node:crypto')
const REPLAY_WINDOW_SECONDS = 300
function verifyCredenShareSignature(secret, rawBody, headerValue) {
if (!headerValue || !Buffer.isBuffer(rawBody)) return false
// Collect the timestamp and EVERY v1 signature.
let timestamp = null
const signatures = []
for (const part of headerValue.split(',')) {
const eq = part.indexOf('=')
if (eq === -1) continue
const key = part.slice(0, eq).trim()
const value = part.slice(eq + 1).trim()
if (key === 't') timestamp = value
else if (key === 'v1') signatures.push(value)
}
if (timestamp === null || signatures.length === 0) return false
// Enforce the replay window before doing any crypto.
const sentAt = Number.parseInt(timestamp, 10)
if (!Number.isInteger(sentAt)) return false
const now = Math.floor(Date.now() / 1000)
if (Math.abs(now - sentAt) > REPLAY_WINDOW_SECONDS) return false
// Sign "<t>.<raw body>" with the full secret, prefix included.
const signed = Buffer.concat([Buffer.from(timestamp + '.', 'utf8'), rawBody])
const expected = crypto.createHmac('sha256', secret).update(signed).digest()
// Constant-time compare against each candidate; accept if any matches.
for (const candidate of signatures) {
const given = Buffer.from(candidate, 'hex')
if (given.length !== expected.length) continue
if (crypto.timingSafeEqual(given, expected)) return true
}
return false
}
Wiring it into Express, taking care to keep the raw body:
const express = require('express')
const app = express()
app.post(
'/credenshare-webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const ok = verifyCredenShareSignature(
process.env.CREDENSHARE_WEBHOOK_SECRET,
req.body,
req.get('X-CredenShare-Signature')
)
if (!ok) return res.status(400).send('invalid signature')
const event = JSON.parse(req.body.toString('utf8'))
// Queue the work, then answer quickly.
res.status(200).send('ok')
}
)
express.raw() hands you a Buffer holding the untouched body. Do not mount express.json() on this route: it replaces the body with a parsed object, and the bytes that were signed are gone.
Python
import hashlib
import hmac
import time
REPLAY_WINDOW_SECONDS = 300
def verify_credenshare_signature(secret: str, raw_body: bytes, header: str) -> bool:
if not header:
return False
# Collect the timestamp and EVERY v1 signature.
timestamp = None
signatures = []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t":
timestamp = value
elif key == "v1":
signatures.append(value)
if timestamp is None or not signatures:
return False
# Enforce the replay window before doing any crypto.
try:
sent_at = int(timestamp)
except ValueError:
return False
if abs(int(time.time()) - sent_at) > REPLAY_WINDOW_SECONDS:
return False
# Sign "<t>.<raw body>" with the full secret, prefix included.
signed = timestamp.encode("ascii") + b"." + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
# Constant-time compare against each candidate; accept if any matches.
return any(
hmac.compare_digest(expected.encode("ascii"), candidate.encode("utf-8"))
for candidate in signatures
)
In Flask, read the body with request.get_data() — never request.get_json() — so that you hash the bytes that were actually signed.
== on the hex strings leaks, through timing, how many leading characters a guess got right, which turns forging a signature into a character-by-character search. Use crypto.timingSafeEqual, hmac.compare_digest, or your language's equivalent.What to return when verification fails
If verification fails, the cause is almost always configuration on your side: the wrong secret, a body that was parsed before it was hashed, or a skewed clock.
Your status code decides what happens to that delivery. A 400 marks it permanently failed, and CredenShare will not attempt it again. If you would rather the delivery be retried while you fix the problem, answer 500 — it stays on the retry schedule. See Delivery and retries.
Rotating the secret
Rotate an endpoint's secret from the Webhooks card in your account's Security settings, or with the hosted MCP server's rotate_webhook_secret tool. Either way the new secret is shown once, at that moment, and not again.
| Behaviour | |
|---|---|
| Grace window | 24 hours from the moment you rotate. |
| During the window | Every delivery carries two v1 values: the new secret's signature first, then the previous secret's. |
| After the window | Only the new secret signs. The previous one stops verifying. |
| Deliveries in flight | Anything not yet sent — queued, or failed and awaiting a retry — is signed with the new secret plus the old one for the rest of the window. Deliveries already sent are untouched, and nothing is re-signed retroactively. |
Because both signatures are present for a full day, you can roll your configuration at your own pace without dropping a delivery: rotate, deploy the new secret whenever your release process allows, and the old one keeps verifying until you have.
If an endpoint is ever shown as disabled with a reason saying the signing key changed, its secret can no longer produce verifiable signatures and rotation will not recover it. Create a new endpoint and configure your receiver with the new secret.
Common mistakes
| Mistake | Symptom |
|---|---|
Stripping or decoding the whsec_ prefix before using the secret as the key | Every signature fails, on every delivery |
| Hashing the secret and using the digest as the key | Every signature fails, on every delivery |
HMAC over the body alone, without <t>. in front | Every signature fails |
| Re-serializing the parsed JSON before hashing | Fails intermittently and unreproducibly, depending on the payload |
Reading only one v1 value | Works, then fails for 24 hours after every rotation |
| No replay-window check | Signatures verify, but a captured delivery can be re-posted indefinitely |
Comparing hex strings with == | Verifies correctly, but leaks the timing information that makes a forgery practical |