Skip to main content

Webhook Security

Security

Protect your webhook endpoints with signature verification, replay attack prevention, and security best practices. Essential reading for production deployments.

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

Always verify webhook signatures before processing payloads. Unverified webhooks can be forged by attackers, leading to data corruption or security breaches.

Signature Format

Every webhook request includes an X-Webhook-Signature header containing a timestamp and HMAC-SHA256 signature.

Signature Header Format
X-Webhook-Signature: t=1705315800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
PropertyTypeDescription
trequirednumberUnix timestamp when the signature was generated
v1requiredstringHMAC-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.

Signature Generation
// 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.

1

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=', '')
2

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')
}
3

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')
4

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

verify-webhook.ts
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.

Replay Prevention Example
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

Store processed event IDs in a fast key-value store like Sylphx KV with automatic expiration. You only need to track events for as long as your timestamp tolerance plus a safety buffer (e.g., 1 hour).

HTTPS Requirements

All webhook endpoints must use HTTPS to ensure data is encrypted in transit. HTTP endpoints are rejected.

Accepted

https://api.example.com/webhooks

Rejected

http://api.example.com/webhooks

Certificate 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

For local development, use tools like ngrok or Cloudflare Tunnel that provide HTTPS endpoints automatically. See our Testing Guide for setup instructions.

IP Allowlisting

For additional security, you can configure your firewall to only accept webhook requests from our IP ranges.

Webhook 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

While IP allowlisting adds security, rely primarily on signature verification. IP ranges may change with infrastructure updates. Subscribe to status.sylphx.com for notifications.

Firewall Configuration Examples

nginx.conf
# 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.

1

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)
2

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')
}
3

Deploy your changes

Deploy the updated verification code to all your webhook endpoints.

4

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_xxx

Automated Rotation

Set up automated secret rotation on a schedule (e.g., every 90 days) using our API. Integrate with your secrets manager for seamless rotation.
Automated Rotation Script
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.