Skip to content

Webhook Security

Every webhook request includes an X-Webhook-Signature header containing an HMAC-SHA256 signature. Always verify this signature to ensure the request came from TRST and hasn't been tampered with.

Why Verify Signatures?

Without signature verification, an attacker could:

  • Send fake webhook events to your endpoint
  • Trigger unauthorized actions in your system
  • Cause data integrity issues

Signature verification ensures:

  • Authenticity - The request came from TRST
  • Integrity - The payload hasn't been modified
  • Non-repudiation - TRST sent this specific payload

Signature Format

The signature header contains a prefix followed by the hex-encoded HMAC:

X-Webhook-Signature: sha256=<hex-encoded-signature>

Verification Process

1. Extract the Signature

Get the signature from the request header:

javascript
const signatureHeader = request.headers["x-webhook-signature"]
// Example: "sha256=a1b2c3d4e5f6..."

2. Get the Raw Request Body

Important: You must use the raw request body (before JSON parsing):

javascript
// Express.js - use express.raw() middleware
app.post("/webhooks/trst", express.raw({ type: "application/json" }), (req, res) => {
	const rawBody = req.body // Buffer containing raw bytes
	// ...
})

3. Compute the Expected Signature

Use HMAC-SHA256 with your webhook secret:

javascript
const crypto = require("crypto")

const hmac = crypto.createHmac("sha256", webhookSecret)
hmac.update(rawBody)
const expectedSignature = hmac.digest("hex")

4. Compare Signatures

Use constant-time comparison to prevent timing attacks:

javascript
if (
	!crypto.timingSafeEqual(
		Buffer.from(receivedSignature, "hex"),
		Buffer.from(expectedSignature, "hex"),
	)
) {
	throw new Error("Invalid signature")
}

Security Best Practices

1. Use Constant-Time Comparison

Never use === or == for signature comparison:

javascript
// ❌ INSECURE - Vulnerable to timing attacks
if (receivedSignature === expectedSignature) {
	// ...
}

// ✅ SECURE - Constant-time comparison
if (
	crypto.timingSafeEqual(
		Buffer.from(receivedSignature, "hex"),
		Buffer.from(expectedSignature, "hex"),
	)
) {
	// ...
}

Timing attacks can leak information about the signature by measuring how long comparisons take.

2. Store Secrets Securely

Never hardcode webhook secrets:

javascript
// ❌ BAD - Hardcoded secret
const secret = "whsec_a1b2c3d4e5f6"

// ✅ GOOD - Environment variable
const secret = process.env.TRST_WEBHOOK_SECRET

// ✅ BETTER - Secrets manager (AWS, GCP, etc.)
const secret = await secretsManager.getSecret("TRST_WEBHOOK_SECRET")

Best practices for secret storage:

  • Use environment variables for development
  • Use a secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.) for production
  • Rotate secrets periodically
  • Never commit secrets to version control

3. Verify Before Processing

Always verify the signature before parsing or processing the payload:

javascript
// ✅ CORRECT ORDER
verifySignature(rawBody, signature, secret) // 1. Verify
const event = JSON.parse(rawBody) // 2. Parse
processEvent(event) // 3. Process

// ❌ WRONG ORDER - Vulnerable to attacks
const event = JSON.parse(rawBody) // Don't parse first!
processEvent(event) // Don't process unverified data!
verifySignature(rawBody, signature, secret) // Too late!

4. Use HTTPS for Your Endpoint

Your webhook endpoint must use HTTPS:

  • Protects webhook payloads in transit
  • Prevents man-in-the-middle attacks
  • Required by TRST (HTTP endpoints are rejected)

5. Validate the Payload

After signature verification, validate the payload structure:

javascript
function validateWebhookPayload(event) {
	if (!event.event_id || typeof event.event_id !== "string") {
		throw new Error("Missing or invalid event_id")
	}

	if (!event.event_type || typeof event.event_type !== "string") {
		throw new Error("Missing or invalid event_type")
	}

	if (!event.timestamp) {
		throw new Error("Missing timestamp")
	}

	if (!event.data || typeof event.data !== "object") {
		throw new Error("Missing or invalid event data")
	}

	return true
}

6. Rate Limit Webhook Endpoints

Protect your endpoint from abuse:

javascript
const rateLimit = require("express-rate-limit")

const webhookLimiter = rateLimit({
	windowMs: 15 * 60 * 1000, // 15 minutes
	max: 1000, // Limit each IP to 1000 requests per window
	message: "Too many webhook requests",
})

app.post("/webhooks/trst", webhookLimiter, webhookHandler)

7. Log Security Events

Log all verification failures for security monitoring:

javascript
function verifyWebhookSignature(request, secret) {
	try {
		// Verification logic...
		return true
	} catch (error) {
		// Log security event
		logger.security({
			event: "webhook_verification_failed",
			ip: request.ip,
			error: error.message,
			timestamp: new Date().toISOString(),
		})

		throw error
	}
}

Troubleshooting

Signature Verification Failing

Common causes:

  1. Using parsed body instead of raw body

    javascript
    // ❌ Wrong - body was already parsed
    app.use(express.json())
    app.post("/webhooks", (req, res) => {
    	const body = JSON.stringify(req.body) // This won't match!
    })
    
    // ✅ Correct - use raw body
    app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
    	const body = req.body // Raw Buffer
    })
  2. Wrong secret

    • Verify you're using the correct webhook secret
    • Check for extra whitespace or newlines
    • Secrets are case-sensitive
  3. Not removing 'sha256=' prefix

    javascript
    // ❌ Wrong
    const signature = header // Includes 'sha256='
    
    // ✅ Correct
    const signature = header.substring(7) // Remove 'sha256='
  4. Character encoding issues

    javascript
    // ✅ Ensure consistent encoding
    const hmac = crypto.createHmac("sha256", secret)
    hmac.update(rawBody, "utf8") // Explicit encoding if needed

Testing Signature Verification

Generate a test signature locally:

javascript
const crypto = require("crypto")

const payload = JSON.stringify({
	event_id: "test_123",
	event_type: "webhook.test",
	timestamp: "2024-11-02T15:00:00Z",
	project_id: "proj_test",
	data: { test_id: "test_123", message: "Test", triggered_at: "2024-11-02T15:00:00Z" },
})

const secret = "your_webhook_secret"

const hmac = crypto.createHmac("sha256", secret)
hmac.update(payload)
const signature = "sha256=" + hmac.digest("hex")

console.log("Signature:", signature)

// Use this signature in X-Webhook-Signature header when testing

Next Steps