HMAC-SHA256 Signatures
Every request is signed with your unique secret
Timestamp Validation
Reject stale requests to prevent replay attacks
HTTPS Only
All webhook deliveries use encrypted connections
Secret Rotation
Rotate secrets without downtime
Security Critical
Signature Format
Every webhook request includes an X-Webhook-Signature header containing a timestamp and HMAC-SHA256 signature.
X-Webhook-Signature: t=1705315800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd| Property | Type | Description |
|---|---|---|
trequired | number | Unix timestamp when the signature was generated |
v1required | string | HMAC-SHA256 signature (hex encoded) |
How Signatures Are Generated
The signature is computed by concatenating the timestamp with the raw request body, separated by a period, then computing the HMAC-SHA256 hash using your webhook secret.
// Signed payload format
const signedPayload = `${timestamp}.${requestBody}`
// HMAC-SHA256 signature
const signature = hmac('sha256', webhookSecret)
.update(signedPayload)
.digest('hex')
// Final header value
const header = `t=${timestamp},v1=${signature}`Verifying Signatures
Follow these steps to verify that a webhook request is authentic and has not been tampered with.
Extract the timestamp and signature
Parse the X-Webhook-Signature header to extract the timestamp and signature components.
const signature = req.headers.get('X-Webhook-Signature')
const [timestampPart, signaturePart] = signature.split(',')
const timestamp = parseInt(timestampPart.replace('t=', ''), 10)
const receivedSig = signaturePart.replace('v1=', '')Check the timestamp
Verify the timestamp is within your tolerance window (recommended: 5 minutes) to prevent replay attacks.
const TOLERANCE_SECONDS = 300 // 5 minutes
const now = Math.floor(Date.now() / 1000)
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) {
throw new Error('Webhook timestamp outside tolerance window')
}Compute the expected signature
Create the signed payload and compute the expected HMAC-SHA256 signature using your webhook secret.
import crypto from 'crypto'
const payload = await req.text() // Raw request body
const signedPayload = `${timestamp}.${payload}`
const expectedSig = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(signedPayload)
.digest('hex')Compare signatures securely
Use a constant-time comparison to prevent timing attacks.
const isValid = crypto.timingSafeEqual(
Buffer.from(receivedSig),
Buffer.from(expectedSig)
)
if (!isValid) {
throw new Error('Invalid webhook signature')
}Complete Verification Examples
import crypto from 'crypto'
interface VerifyOptions {
payload: string
signature: string
secret: string
tolerance?: number // seconds
}
export function verifyWebhookSignature({
payload,
signature,
secret,
tolerance = 300,
}: VerifyOptions): boolean {
// Parse signature header
const parts = signature.split(',')
const timestampPart = parts.find(p => p.startsWith('t='))
const sigPart = parts.find(p => p.startsWith('v1='))
if (!timestampPart || !sigPart) {
throw new Error('Invalid signature format')
}
const timestamp = parseInt(timestampPart.slice(2), 10)
const receivedSig = sigPart.slice(3)
// Check timestamp tolerance
const now = Math.floor(Date.now() / 1000)
if (Math.abs(now - timestamp) > tolerance) {
throw new Error(`Timestamp outside ${tolerance}s tolerance`)
}
// Compute expected signature
const signedPayload = `${timestamp}.${payload}`
const expectedSig = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex')
// Constant-time comparison
try {
return crypto.timingSafeEqual(
Buffer.from(receivedSig, 'hex'),
Buffer.from(expectedSig, 'hex')
)
} catch {
return false
}
}
// Usage in API route
export async function POST(req: Request) {
const payload = await req.text()
const signature = req.headers.get('X-Webhook-Signature')
if (!signature) {
return new Response('Missing signature', { status: 401 })
}
try {
const isValid = verifyWebhookSignature({
payload,
signature,
secret: process.env.WEBHOOK_SECRET!,
})
if (!isValid) {
return new Response('Invalid signature', { status: 401 })
}
const event = JSON.parse(payload)
await processWebhook(event)
return new Response('OK', { status: 200 })
} catch (error) {
console.error('Webhook verification failed:', error)
return new Response('Verification failed', { status: 401 })
}
}Replay Attack Prevention
Replay attacks occur when an attacker intercepts a valid webhook request and resends it later. Protect against this with timestamp validation and idempotency tracking.
Timestamp Validation
Reject requests with timestamps older than 5 minutes. This limits the window for replay attacks.
Idempotency Keys
Track processed event IDs to prevent duplicate processing if the same event is delivered multiple times.
import { verifyWebhook } from '@sylphx/sdk'
export async function POST(req: Request) {
const payload = await req.text()
const signature = req.headers.get('X-Webhook-Signature')!
// 1. Verify signature (includes timestamp check)
const event = verifyWebhook(payload, signature, process.env.WEBHOOK_SECRET!, {
tolerance: 300, // 5 minute tolerance
})
// 2. Check for duplicate delivery using event ID
const alreadyProcessed = await db.processedEvents.findUnique({
where: { eventId: event.id },
})
if (alreadyProcessed) {
console.log('Duplicate event, skipping:', event.id)
return new Response('Already processed', { status: 200 })
}
// 3. Process the event
await processWebhook(event)
// 4. Mark as processed with TTL for cleanup
await db.processedEvents.create({
data: {
eventId: event.id,
processedAt: new Date(),
// Auto-delete after 7 days
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
})
return new Response('OK', { status: 200 })
}Event ID Storage
HTTPS Requirements
All webhook endpoints must use HTTPS to ensure data is encrypted in transit. HTTP endpoints are rejected.
Accepted
https://api.example.com/webhooksRejected
http://api.example.com/webhooksCertificate Requirements
- Valid SSL/TLS certificate from a trusted Certificate Authority
- Certificate must not be expired
- Certificate Common Name or SAN must match the webhook URL domain
- Self-signed certificates are not accepted in production
Development Environments
IP Allowlisting
For additional security, you can configure your firewall to only accept webhook requests from our IP ranges.
# Production webhook delivery IPs
52.89.214.238/32
34.212.75.30/32
54.218.53.128/32
52.32.215.164/32
# These IPs may be updated. Subscribe to our status page
# for notifications about IP range changes.IP Ranges May Change
Firewall Configuration Examples
# Only allow webhook IPs to access the webhook endpoint
location /api/webhooks {
# Sylphx webhook IPs
allow 52.89.214.238;
allow 34.212.75.30;
allow 54.218.53.128;
allow 52.32.215.164;
deny all;
proxy_pass http://backend;
}Rotating Webhook Secrets
Rotate your webhook secret periodically or immediately if you suspect it has been compromised. Our platform supports zero-downtime rotation with dual secrets.
Generate a new secret
Go to your webhook settings in the console and click "Add Secret". This creates a new signing secret while keeping the old one active.
// Console: Settings > Webhooks > Select webhook > Security
// Click "Add Secret" to generate a new signing secret
// Your webhook will now have two active secrets:
// - Primary: whsec_new_xxx (new)
// - Secondary: whsec_old_xxx (previous)Update your verification code
Modify your webhook handler to try both secrets. This ensures you can process webhooks signed with either secret during the transition.
const secrets = [
process.env.WEBHOOK_SECRET_NEW,
process.env.WEBHOOK_SECRET_OLD,
].filter(Boolean)
function verifyWithMultipleSecrets(payload: string, signature: string) {
for (const secret of secrets) {
try {
return verifyWebhook(payload, signature, secret)
} catch {
continue // Try next secret
}
}
throw new Error('Invalid signature with all secrets')
}Deploy your changes
Deploy the updated verification code to all your webhook endpoints.
Remove the old secret
After deployment is complete and all in-flight webhooks have been processed (wait at least 5 minutes), remove the old secret from the console and your environment variables.
// Console: Settings > Webhooks > Select webhook > Security
// Click "Remove" next to the old secret
// Update your environment to only use the new secret
WEBHOOK_SECRET=whsec_new_xxxAutomated Rotation
import { platform } from '@/lib/platform'
async function rotateWebhookSecret(webhookId: string) {
// 1. Add new secret
const { secrets } = await platform.webhooks.addSecret(webhookId)
// 2. Update secrets manager (e.g., AWS Secrets Manager)
await secretsManager.updateSecret({
SecretId: 'webhook-secrets',
SecretString: JSON.stringify({
primary: secrets[0].key,
secondary: secrets[1]?.key,
}),
})
// 3. Wait for deployment to propagate
await new Promise(resolve => setTimeout(resolve, 5 * 60 * 1000))
// 4. Remove old secret
if (secrets.length > 1) {
await platform.webhooks.removeSecret(webhookId, secrets[1].id)
}
console.log('Secret rotation complete for webhook:', webhookId)
}Security Checklist
Use this checklist to ensure your webhook implementation follows security best practices.
Verify signatures on every request
Never process unverified payloads
Validate timestamps
Reject requests older than 5 minutes
Use constant-time comparison
Prevent timing attacks with timingSafeEqual
Use HTTPS endpoints only
Ensure all webhook URLs use TLS encryption
Track processed event IDs
Prevent duplicate processing with idempotency
Store secrets securely
Use environment variables or secrets manager
Rotate secrets periodically
Rotate every 90 days or on suspected compromise
Configure IP allowlisting (optional)
Additional layer for high-security environments
Set up monitoring and alerts
Track verification failures and delivery issues
Common Security Mistakes
Skipping signature verification
Processing webhooks without verification allows attackers to send forged requests.
Fix: Always verify signatures using the SDK or manual HMAC computation.
Using string comparison for signatures
Regular string comparison is vulnerable to timing attacks that can leak the secret.
Fix: Use crypto.timingSafeEqual() or hmac.compare_digest() for constant-time comparison.
Ignoring timestamp validation
Without timestamp checks, old captured requests can be replayed.
Fix: Reject requests with timestamps outside a 5-minute window.
Hardcoding webhook secrets
Secrets in code can be exposed in version control.
Fix: Use environment variables or a secrets manager.
Not logging verification failures
Failed verifications may indicate an attack.
Fix: Log failures with request details for security monitoring.