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.
Verification implementation
Section titled “Verification implementation”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;}Express setup with raw body access
Section titled “Express setup with raw body access”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);});Replay protection
Section titled “Replay protection”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.
Key rotation
Section titled “Key rotation”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).