All docs
2 min read

Signing & verification

Every webhook delivery is signed with HMAC-SHA256 over the raw request body using your webhook's secret. Verify before you trust.

Header

X-Formspring-Signature: 5b8ad...e3

Hex-encoded HMAC-SHA256 digest. Compare in constant time.

Verify in Node.js

import { createHmac } from 'node:crypto';

export default function handler(req, res) {
  const sig = req.headers['x-formspring-signature'];
  const expected = createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(req.rawBody)
    .digest('hex');

  if (
    sig.length !== expected.length ||
    !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
  ) {
    return res.status(401).end();
  }

  // safe to use req.body now
}

Verify in PHP

$sig = $request->header('X-Formspring-Signature');
$expected = hash_hmac('sha256', $request->getContent(), env('WEBHOOK_SECRET'));

if (! hash_equals($expected, $sig)) {
    abort(401);
}

Verify in Python

import hmac, hashlib

sig = request.headers["X-Formspring-Signature"]
expected = hmac.new(
    secret.encode(), body_bytes, hashlib.sha256
).hexdigest()

if not hmac.compare_digest(expected, sig):
    return 401

Verify in Ruby

expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
unless Rack::Utils.secure_compare(expected, signature)
  halt 401
end

Why constant-time compare

A naive == leaks information about how many bytes matched, which an attacker can use to forge signatures one byte at a time. Use timingSafeEqual / hash_equals / compare_digest / secure_compare instead.

Rotate the secret

To rotate, delete the webhook and create a new one. The new webhook gets a fresh secret; flip your service over to the new URL or update the existing destination URL via the API.