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:

Header Value Purpose
X-Chronos-Signature sha256=<hex> HMAC-SHA256 signature
X-Chronos-Timestamp Unix seconds When Chronos sent the request
X-Chronos-Delivery-Id UUID Same 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.

The @chronos.sh/sdk package includes a Webhook class that handles signature verification, timestamp validation, and key rotation.

Terminal window
pnpm add @chronos.sh/sdk
import { Webhook } from '@chronos.sh/sdk';
const wh = new Webhook(process.env.CHRONOS_SIGNING_SECRET!);
const payload = await wh.verify(rawBody, headers);

verify() returns the parsed JSON body on success and throws ChronosWebhookVerificationError on failure. It is async because it uses the Web Crypto API internally, which works in Node.js 18+, Bun, Deno, and edge runtimes.

Headers are matched case-insensitively. You can pass a plain object (req.headers in Express) or a Web standard Headers instance (Hono, Cloudflare Workers, Deno).

Express parses the body by default, so you need to capture the raw bytes. Use the verify callback on express.json() to store the original buffer alongside the parsed body:

server.ts
import express from 'express';
import { Webhook, ChronosWebhookVerificationError } from '@chronos.sh/sdk';
const wh = new Webhook(process.env.CHRONOS_SIGNING_SECRET!);
const app = express();
app.post('/hooks/chronos', express.json({
verify: (req, _res, buf) => {
(req as any).rawBody = buf.toString('utf-8');
},
}), async (req, res) => {
try {
const payload = await wh.verify((req as any).rawBody, req.headers);
// Signature valid — process the job
const { handler } = payload as { handler: string };
// ...
res.sendStatus(200);
} catch (err) {
if (err instanceof ChronosWebhookVerificationError) {
return res.sendStatus(401);
}
throw err;
}
});

Pass an array of secrets to verify against multiple keys during rotation. Chronos keeps both the old and new keys valid for 24 hours after rotation.

const wh = new Webhook([
process.env.CHRONOS_SIGNING_SECRET!,
process.env.CHRONOS_SIGNING_SECRET_PREVIOUS!,
]);

The SDK tries each key in order and succeeds on the first match. Pass all secrets at construction time; keys are cached after the first verify() call.

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

By default, verify() rejects requests with timestamps more than 5 minutes from the current time (in either direction). Adjust the window or disable it entirely:

// Tighter window (60 seconds)
const wh = new Webhook(secret, { toleranceSeconds: 60 });
// Disable timestamp check entirely
const wh = new Webhook(secret, { toleranceSeconds: 0 });

All failures throw ChronosWebhookVerificationError. The message indicates what failed.

Constructor errors:

Message Cause
At least one non-empty signing secret is required No valid secret provided (empty string or empty array)
toleranceSeconds must be a finite non-negative number Negative, Infinity, or NaN tolerance value

Verification errors (thrown by verify()):

Message Cause
Missing required header: <name> One of the three signature headers is missing
Invalid timestamp Timestamp is not a positive integer
Timestamp outside tolerance Request is too old or too far in the future
Invalid signature format: expected sha256=<hex> Signature header doesn’t start with sha256=
Invalid signature: expected 32-byte SHA-256 digest Hex digest is empty or wrong length
Invalid signature: malformed hex Non-hex characters in digest
Signature verification failed Valid format, wrong secret or tampered body
Verified payload is not valid JSON Signature matched but body isn’t valid JSON
import { Webhook, ChronosWebhookVerificationError } from '@chronos.sh/sdk';
const wh = new Webhook(process.env.CHRONOS_SIGNING_SECRET!);
try {
const payload = await wh.verify(rawBody, headers);
} catch (err) {
if (err instanceof ChronosWebhookVerificationError) {
console.error('Verification failed:', err.message);
return new Response('Invalid signature', { status: 401 });
}
}

Return 401 on verification failure. 4xx responses are terminal; Chronos won’t retry.

If you can’t use the SDK or want to understand the protocol, here’s a standalone implementation using Node.js crypto:

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;
}

Key implementation details:

  • Use timingSafeEqual (not ===) to prevent timing attacks
  • Validate the sha256= prefix and 64-character hex digest before comparing
  • Check timestamp freshness to prevent replay attacks

To handle rotation, 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);
});
}