Validating Webhook Signatures
Developers integrating with Crewsum webhooks need to validate incoming requests to confirm they originate from Crewsum. This guide explains how to implement signature validation using the x-crewsum-signature and x-timestamp headers with your webhook secret key. By following these steps, you protect your application from unauthorized webhook requests and potential security threats.
Prerequisites
Section titled “Prerequisites”Before implementing signature validation, ensure you have:
- A Crewsum webhook secret key (available in your webhook configuration)
- Node.js with Express.js or your preferred web framework
- Access to cryptographic libraries (built-in
cryptomodule for Node.js)
How Signature Validation Works
Section titled “How Signature Validation Works”Crewsum signs each webhook request using HMAC-SHA256. The signature combines a timestamp and the request body, ensuring both integrity and timeliness.
Signature Components
Section titled “Signature Components”- Timestamp: Unix timestamp in the
x-timestampheader prevents replay attacks - Signature: HMAC-SHA256 hash in the
x-crewsum-signatureheader - Payload: Concatenation of timestamp and raw request body separated by a dot
Validation Steps
Section titled “Validation Steps”- Extract timestamp and signature from headers
- Verify timestamp is within acceptable time window (5 minutes)
- Reconstruct payload using timestamp and raw body
- Generate expected signature using your webhook key
- Compare signatures using timing-safe equality
Implementation
Section titled “Implementation”import express, { Request, Response } from "express";import crypto from "crypto";
const app = express();
// Parse JSON bodies but keep raw body for signature validationapp.use( express.json({ verify: (req: Request, res: Response, buf: Buffer) => { (req as any).rawBody = buf; }, }));
const WEBHOOK_SECRET = process.env.CREWSUM_WEBHOOK_SECRET || "your-webhook-secret";
function validateWebhookSignature(req: Request): boolean { const timestamp = req.headers["x-timestamp"] as string; const signature = req.headers["x-crewsum-signature"] as string;
// Validate required headers and body if (!timestamp || !signature || !(req as any).rawBody) { console.error("Missing required headers or body for signature validation"); return false; }
const currentTime = Math.floor(Date.now() / 1000); const requestTime = parseInt(timestamp, 10);
// Check timestamp is within 5-minute window if (Math.abs(currentTime - requestTime) > 300) { console.error("Webhook timestamp is outside acceptable time window"); return false; }
// Reconstruct payload: timestamp.body const payload = `${timestamp}.${(req as any).rawBody.toString()}`;
// Generate expected signature const expectedSignature = crypto .createHmac("sha256", WEBHOOK_SECRET) .update(payload) .digest("hex");
// Use timing-safe comparison to prevent timing attacks return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expectedSignature) );}
app.post("/webhooks/crewsum", (req: Request, res: Response) => { // Validate signature first if (!validateWebhookSignature(req)) { return res.status(401).json({ error: "Invalid webhook signature", }); }
// Process validated webhook const { event, data } = req.body;
// Handle different event types switch (event) { case "project.created": // Handle project creation break; case "timesheet.submitted": // Handle timesheet submission break; default: console.log(`Unhandled event: ${event}`); }
res.status(200).json({ status: "ok" });});
app.listen(3000, () => { console.log("Webhook server running on port 3000");});// Generic validation function that works with any Node.js frameworkimport crypto from "crypto";
export function validateCrewsumWebhook( rawBody: string | Buffer, signature: string, timestamp: string, webhookSecret: string): boolean { if (!signature || !timestamp || !rawBody) { return false; }
const currentTime = Math.floor(Date.now() / 1000); const requestTime = parseInt(timestamp, 10);
// 5-minute tolerance for timestamp if (Math.abs(currentTime - requestTime) > 300) { return false; }
const payload = `${timestamp}.${rawBody.toString()}`; const expectedSignature = crypto .createHmac("sha256", webhookSecret) .update(payload) .digest("hex");
return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expectedSignature) );}Security Considerations
Section titled “Security Considerations”- Store webhook secrets securely: Use environment variables, never hardcode them
- Validate timestamps: Prevents replay attacks by rejecting old requests
- Use timing-safe comparison: Avoids timing attack vulnerabilities
- Handle raw body correctly: Ensure you access the unmodified request body
- Implement proper error handling: Don’t expose internal details in error responses
- Monitor failed validations: Log and alert on repeated signature failures
Testing Your Implementation
Section titled “Testing Your Implementation”Test your validation with sample webhooks from your Crewsum dashboard. Verify that:
- Valid signatures are accepted
- Invalid signatures are rejected with 401 status
- Requests with expired timestamps are rejected
- Requests missing headers are handled gracefully
This implementation ensures your webhook endpoints remain secure and only process legitimate requests from Crewsum.