Skip to content
CredenShare
Webhooks

Verifying signatures

How CredenShare signs every webhook delivery, and how to verify one correctly.

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>
KeyMeaning
tThe moment this attempt was signed, as decimal Unix seconds. Present exactly once.
v1A 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>
Collect everyv1 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.

Hash the raw bytes as received. Do not parse the JSON and re-serialize it before hashing. The byte sequence CredenShare signs is the body exactly as it is sent; its key order and whitespace are not guaranteed to survive a decode-and-re-encode round trip in your language, and any difference at all produces a completely different HMAC. In practice this means capturing the body before your web framework's JSON parser reaches it.

The key is the whole secret

The HMAC key is the full secret string, including its whsec_ prefix, taken as bytes.

This is the detail that most often goes wrong. The key is the entire issued string, treated as opaque text. Do not strip the 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.

Keep your receiver's clock synchronised. A host whose clock has drifted more than five minutes rejects every delivery, and the symptom is indistinguishable from a wrong secret.

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:

Node
import { webhooks } from '@credenshare/sdk'
await webhooks.verify(rawBody, header, [NEW_SECRET, OLD_SECRET])
Python
from credenshare import webhooks
webhooks.verify(raw_body, header, secrets=[NEW_SECRET, OLD_SECRET])
Go
import "github.com/CredenShare/credenshare-sdk-go/webhooks"
err := webhooks.Verify(rawBody, header, []string{newSecret, oldSecret}, nil)
Rust
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.

Compare in constant time. A plain == 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 window24 hours from the moment you rotate.
During the windowEvery delivery carries two v1 values: the new secret's signature first, then the previous secret's.
After the windowOnly the new secret signs. The previous one stops verifying.
Deliveries in flightAnything 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.

Only one previous secret is retained. Rotating twice inside the same 24-hour window discards the oldest immediately, so any receiver still using the original secret starts failing at once. Let the window close before you rotate again.

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

MistakeSymptom
Stripping or decoding the whsec_ prefix before using the secret as the keyEvery signature fails, on every delivery
Hashing the secret and using the digest as the keyEvery signature fails, on every delivery
HMAC over the body alone, without <t>. in frontEvery signature fails
Re-serializing the parsed JSON before hashingFails intermittently and unreproducibly, depending on the payload
Reading only one v1 valueWorks, then fails for 24 hours after every rotation
No replay-window checkSignatures 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

The official SDKs verify signatures in one line, and are the shortest path through this page. If you are evaluating rather than integrating, start free.