Skip to content

Signature Verification

Without signature verification, anyone who discovers your endpoint URL can send fake job payloads. Verification proves the request came from Chronos and hasn’t been tampered with.

Every push delivery includes three headers for verification:

HeaderValuePurpose
X-Chronos-Signaturesha256=<hex>HMAC-SHA256 signature
X-Chronos-TimestampUnix secondsWhen Chronos sent the request
X-Chronos-Delivery-IdUUIDSame as execution_id in the body

The signature covers three values joined by dots:

{execution_id}.{timestamp}.{raw_body}
  • execution_id: the execution_id field from the request body (also in X-Chronos-Delivery-Id)
  • timestamp: the X-Chronos-Timestamp header value
  • raw_body: the raw JSON string of the request body (not parsed, not re-serialized)

Find your signing secret in the dashboard under Settings → Signing Key.

import { createHmac, timingSafeEqual } from 'node:crypto';
import { Buffer } from 'node:buffer';
import type { Request } from 'express';
const SIGNING_SECRET = process.env.CHRONOS_SIGNING_SECRET!;
const MAX_AGE_SECONDS = 300;
function verifyChronosSignature(req: Request, rawBody: string): boolean {
const signature = getHeader(req, 'x-chronos-signature');
const timestamp = getHeader(req, 'x-chronos-timestamp');
const executionId = getHeader(req, 'x-chronos-delivery-id');
if (!signature || !timestamp || !executionId) {
return false;
}
const signatureDigest = parseChronosSignature(signature);
if (!signatureDigest) {
return false;
}
if (!/^\d+$/.test(timestamp)) {
return false;
}
const requestTime = Number(timestamp);
const now = Math.floor(Date.now() / 1000);
if (!Number.isSafeInteger(requestTime) || Math.abs(now - requestTime) > MAX_AGE_SECONDS) {
return false;
}
const signedPayload = `${executionId}.${timestamp}.${rawBody}`;
const expectedDigest = createHmac('sha256', SIGNING_SECRET)
.update(signedPayload)
.digest();
return timingSafeEqual(signatureDigest, expectedDigest);
}
function getHeader(req: Request, name: string): string | null {
const value = req.headers[name];
return typeof value === 'string' ? value : null;
}
function parseChronosSignature(signature: string): Buffer | null {
const match = /^sha256=([a-f0-9]{64})$/i.exec(signature);
const digest = match?.[1];
return digest ? Buffer.from(digest, 'hex') : null;
}
server.ts
import express from 'express';
const app = express();
app.post('/hooks/chronos', express.json({
verify: (req, _res, buf) => {
(req as any).rawBody = buf.toString('utf-8');
},
}), (req, res) => {
const rawBody = (req as any).rawBody;
if (!verifyChronosSignature(req, rawBody)) {
return res.sendStatus(401);
}
// Signature valid - process the job
const { handler, payload } = req.body;
// ...
res.sendStatus(200);
});

The verifyChronosSignature function above rejects requests older than 5 minutes (MAX_AGE_SECONDS = 300). This prevents replay attacks where a captured request is re-sent later. Adjust the window based on your tolerance for clock skew.

Rotate your signing secret in the dashboard.

During rotation, both the old and new keys are valid for 24 hours (grace window). This gives you time to update your verification code without dropping deliveries.

To handle rotation gracefully, verify against both keys:

function verifyWithRotation(req: Request, rawBody: string): boolean {
const signature = getHeader(req, 'x-chronos-signature');
const timestamp = getHeader(req, 'x-chronos-timestamp');
const executionId = getHeader(req, 'x-chronos-delivery-id');
if (!signature || !timestamp || !executionId) return false;
const signatureDigest = parseChronosSignature(signature);
if (!signatureDigest) return false;
if (!/^\d+$/.test(timestamp)) return false;
const requestTime = Number(timestamp);
const now = Math.floor(Date.now() / 1000);
if (!Number.isSafeInteger(requestTime) || Math.abs(now - requestTime) > 300) return false;
const keys = [
process.env.CHRONOS_SIGNING_SECRET!,
process.env.CHRONOS_SIGNING_SECRET_PREVIOUS!,
].filter(Boolean);
const signedPayload = `${executionId}.${timestamp}.${rawBody}`;
return keys.some((secret) => {
const expectedDigest = createHmac('sha256', secret)
.update(signedPayload)
.digest();
return timingSafeEqual(signatureDigest, expectedDigest);
});
}

Chronos blocks another rotation until the 24-hour grace window closes (409 signing_key_grace_window_active).