Signature Verification
Why verify
Section titled “Why verify”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.
The signature headers
Section titled “The signature headers”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 |
Signed payload format
Section titled “Signed payload format”The signature covers three values joined by dots:
{execution_id}.{timestamp}.{raw_body}execution_id: theexecution_idfield from the request body (also inX-Chronos-Delivery-Id)timestamp: theX-Chronos-Timestampheader valueraw_body: the raw JSON string of the request body (not parsed, not re-serialized)
Your signing secret
Section titled “Your signing secret”Find your signing secret in the dashboard under Settings → Signing Key.
Verify with the SDK
Section titled “Verify with the SDK”The @chronos.sh/sdk package includes a Webhook class that handles signature verification, timestamp validation, and key rotation.
pnpm add @chronos.sh/sdknpm install @chronos.sh/sdkyarn add @chronos.sh/sdkBasic usage
Section titled “Basic usage”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 setup
Section titled “Express setup”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:
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; }});Key rotation
Section titled “Key rotation”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).
Replay protection
Section titled “Replay protection”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 entirelyconst wh = new Webhook(secret, { toleranceSeconds: 0 });Error handling
Section titled “Error handling”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.
Checklist
Section titled “Checklist”Manual verification
Section titled “Manual verification”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
Key rotation (manual)
Section titled “Key rotation (manual)”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); });}