Skip to main content

Signature verification

Your webhook endpoint is public and can trigger changes in your system. Anyone who finds the URL can send it a request, so you need to verify that a webhook actually came from AletaPlanet before doing anything with it.

Signature verification is required for every webhook.

The signature header​

Each delivery includes an Aleta-Signature header:

Aleta-Signature: t=1785508332,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

The header contains two values:

ElementMeaning
tUnix timestamp, in seconds, when the signature was generated.
v1The HMAC-SHA256 signature, encoded as hexadecimal.

You may see more than one v1 value while a signing secret is being rotated. Accept the request if any of the signatures matches.

How the signature is computed​

The signed value is the timestamp, followed by a literal . and the raw request body:

signed_payload = t + "." + raw_body
signature = hex(hmac_sha256(signing_secret, signed_payload))

The signature is calculated from the exact request body that was sent.

Verify the signature against the original bytes. Don't parse the JSON and then serialize it again before verifying it.

Changing whitespace, key order, escaping, or formatting changes the bytes and therefore changes the signature.

If you're using Express, use express.raw() on the webhook route and make sure it runs before any global express.json() middleware.

Verification steps​

The basic flow is:

  1. Read t and every v1 value from the Aleta-Signature header.
  2. Reject the request if t is more than 5 minutes away from your current time.
  3. Calculate the HMAC using t + "." + raw_body and your webhook signing secret.
  4. Compare the calculated signature with the received signatures using a constant-time comparison.
  5. Only after verification succeeds should you parse the body and process the event.

The timestamp check prevents replay attacks. Without it, someone who captures a valid webhook could send the same request to your endpoint over and over.

The constant-time comparison helps prevent timing attacks that could otherwise reveal the expected signature one byte at a time. Don't use normal string comparisons such as === or == for this.

Your signing secret is created with the endpoint in Cockpit under Developers → Webhook endpoints. It's shown only once.

The signing secret is different from your API access token. Don't use one in place of the other.

Implementations​

import crypto from 'node:crypto';
import express from 'express';

const TOLERANCE_SECONDS = 300;

function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('=').map((s) => s.trim())),
);

const timestamp = Number(parts.t);
if (!timestamp) return false;

if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
return false;
}

const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`, 'utf8')
.digest('hex');

const received = Buffer.from(parts.v1 ?? '', 'utf8');
const computed = Buffer.from(expected, 'utf8');

// timingSafeEqual throws on length mismatch, so check first.
return (
received.length === computed.length &&
crypto.timingSafeEqual(received, computed)
);
}

const app = express();

app.post(
'/webhooks/aleta',
express.raw({ type: 'application/json' }),
(req, res) => {
const raw = req.body.toString('utf8');

if (
!verify(
raw,
req.get('Aleta-Signature') ?? '',
process.env.ALETA_WEBHOOK_SECRET,
)
) {
return res.sendStatus(400);
}

queue.enqueue(JSON.parse(raw));
res.sendStatus(200);
},
);

Rotating the signing secret​

You can rotate a signing secret without taking your webhook endpoint offline:

  1. Generate a second secret in Cockpit. Both secrets become active.
  2. During the overlap, deliveries are signed with both secrets and the header contains two v1 values.
  3. Deploy code that accepts either signature.
  4. Retire the old secret.

The overlap period is what allows you to rotate safely without missing events.

Because the header can contain multiple v1 values, your parser should treat them as a list rather than assuming there will only ever be one.

Common mistakes​

MistakeSymptom
Verifying against a parsed and re-serialized bodyEvery signature fails.
Mounting a global JSON parser before the webhook routeEvery signature fails.
Using == or === for the comparisonThe comparison works, but isn't constant-time.
Skipping the timestamp checkCaptured deliveries can be replayed.
Treating v1 as a single valueVerification breaks during secret rotation.
Using the API access token as the signing secretEvery signature fails.

Next​