Skip to navigation

Secure Your Webhooks

Verify webhook authenticity with signature validation

Webhook signatures allow you to verify that incoming webhooks are genuinely from Typeform and haven’t been tampered with. This is a critical security measure for production webhooks.

Why verify webhooks?

Without verification, anyone who knows your webhook URL could send fake requests to your endpoint. Signature verification ensures:

  • Requests are from Typeform
  • Payload hasn’t been modified
  • Protection against replay attacks
  • Compliance with security best practices

Always verify webhook signatures in production environments. Never trust incoming webhook data without verification.

How webhook signatures work

Typeform includes a signature in the webhook request headers:

  1. Typeform generates a hash of the request body using HMAC-SHA256
  2. The hash is created using a secret key (your form’s webhook secret)
  3. The signature is sent in the Typeform-Signature header
  4. Your server recreates the hash and compares it to the received signature

Getting your webhook secret

When you create or update a webhook, Typeform provides a secret key:

curl -X PUT \
'https://api.typeform.com/forms/{form_id}/webhooks/{tag}' \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"url": "https://your-server.com/webhooks/typeform",
"enabled": true,
"secret": "your_webhook_secret_key"
}'

Response:

{
"id": "webhook_123",
"form_id": "abc123",
"tag": "production",
"url": "https://your-server.com/webhooks/typeform",
"enabled": true,
"secret": "your_webhook_secret_key",
"created_at": "2024-01-15T10:00:00Z"
}

Store the secret securely in your environment variables. Never commit secrets to version control.

Verifying signatures

Node.js implementation

const crypto = require('crypto');
function verifyWebhookSignature(request, secret) {
// Get the signature from the header
const signature = request.headers['typeform-signature'];
if (!signature) {
console.error('No Typeform-Signature header found');
return false;
}
// Extract the signature value (format: sha256=SIGNATURE)
const signatureMatch = signature.match(/sha256=([a-f0-9]+)/);
if (!signatureMatch) {
console.error('Invalid signature format');
return false;
}
const receivedSignature = signatureMatch[1];
// Get the raw request body as a string
const payload =
typeof request.body === 'string'
? request.body
: JSON.stringify(request.body);
// Compute the expected signature
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
// Compare signatures using timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(expectedSignature, 'hex'),
Buffer.from(receivedSignature, 'hex')
);
}
// Usage with Express
const express = require('express');
const app = express();
// Important: Use express.text() to get raw body for signature verification
app.use(express.text({ type: 'application/json' }));
app.post('/webhooks/typeform', (req, res) => {
const secret = process.env.TYPEFORM_WEBHOOK_SECRET;
// Verify the signature
if (!verifyWebhookSignature(req, secret)) {
console.error('Invalid webhook signature');
return res.status(401).send('Unauthorized');
}
// Parse the JSON body
const payload = JSON.parse(req.body);
// Process the verified webhook
processWebhook(payload);
res.status(200).send('OK');
});

Python/Flask implementation

import hmac
import hashlib
from flask import Flask, request, jsonify
app = Flask(__name__)
def verify_webhook_signature(request, secret):
"""Verify the Typeform webhook signature"""
# Get the signature from headers
signature_header = request.headers.get('Typeform-Signature')
if not signature_header:
print('No Typeform-Signature header found')
return False
# Extract the signature value
if not signature_header.startswith('sha256='):
print('Invalid signature format')
return False
received_signature = signature_header[7:] # Remove 'sha256=' prefix
# Get the raw request body
payload = request.get_data(as_text=True)
# Compute the expected signature
expected_signature = hmac.new(
secret.encode('utf-8'),
payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
# Compare signatures using timing-safe comparison
return hmac.compare_digest(expected_signature, received_signature)
@app.route('/webhooks/typeform', methods=['POST'])
def typeform_webhook():
import os
secret = os.environ.get('TYPEFORM_WEBHOOK_SECRET')
# Verify the signature
if not verify_webhook_signature(request, secret):
return jsonify({'error': 'Invalid signature'}), 401
# Process the verified webhook
payload = request.get_json()
process_webhook(payload)
return jsonify({'status': 'success'}), 200
if __name__ == '__main__':
app.run()

Ruby/Sinatra implementation

require 'sinatra'
require 'json'
require 'openssl'
def verify_webhook_signature(request, secret)
# Get the signature from headers
signature_header = request.env['HTTP_TYPEFORM_SIGNATURE']
return false unless signature_header
return false unless signature_header.start_with?('sha256=')
received_signature = signature_header[7..-1]
# Get the raw request body
request.body.rewind
payload = request.body.read
# Compute the expected signature
expected_signature = OpenSSL::HMAC.hexdigest(
'SHA256',
secret,
payload
)
# Compare signatures
Rack::Utils.secure_compare(expected_signature, received_signature)
end
post '/webhooks/typeform' do
secret = ENV['TYPEFORM_WEBHOOK_SECRET']
# Verify the signature
unless verify_webhook_signature(request, secret)
halt 401, 'Unauthorized'
end
# Process the verified webhook
payload = JSON.parse(request.body.read)
process_webhook(payload)
status 200
body 'OK'
end

PHP implementation

<?php
function verifyWebhookSignature($request, $secret) {
// Get the signature from headers
$signatureHeader = $_SERVER['HTTP_TYPEFORM_SIGNATURE'] ?? '';
if (empty($signatureHeader)) {
error_log('No Typeform-Signature header found');
return false;
}
// Extract the signature value
if (strpos($signatureHeader, 'sha256=') !== 0) {
error_log('Invalid signature format');
return false;
}
$receivedSignature = substr($signatureHeader, 7);
// Get the raw request body
$payload = file_get_contents('php://input');
// Compute the expected signature
$expectedSignature = hash_hmac('sha256', $payload, $secret);
// Compare signatures using timing-safe comparison
return hash_equals($expectedSignature, $receivedSignature);
}
// Handle webhook
$secret = getenv('TYPEFORM_WEBHOOK_SECRET');
if (!verifyWebhookSignature($_SERVER, $secret)) {
http_response_code(401);
echo 'Unauthorized';
exit;
}
// Process the verified webhook
$payload = json_decode(file_get_contents('php://input'), true);
processWebhook($payload);
http_response_code(200);
echo 'OK';
?>

Important implementation notes

1. Use raw request body

You must verify the signature against the raw request body string, not a parsed JSON object:

// ❌ Wrong - don't use parsed body
app.use(express.json());
app.post('/webhook', (req, res) => {
const payload = JSON.stringify(req.body); // Won't match!
// ...
});
// ✅ Correct - use raw body
app.use(express.text({ type: 'application/json' }));
app.post('/webhook', (req, res) => {
const payload = req.body; // Raw string
verifySignature(payload);
const data = JSON.parse(payload); // Parse after verification
// ...
});

2. Use timing-safe comparison

Always use timing-safe comparison functions to prevent timing attacks:

// ❌ Wrong - vulnerable to timing attacks
if (expectedSignature === receivedSignature) {
// ...
}
// ✅ Correct - timing-safe comparison
if (
crypto.timingSafeEqual(
Buffer.from(expectedSignature, 'hex'),
Buffer.from(receivedSignature, 'hex')
)
) {
// ...
}

3. Handle missing signatures

Always check for the presence of the signature header:

const signature = request.headers['typeform-signature'];
if (!signature) {
console.error('Missing signature');
return res.status(401).send('Unauthorized');
}

Testing signature verification

Create a test webhook

const crypto = require('crypto');
function createTestWebhook(payload, secret) {
const payloadString = JSON.stringify(payload);
const signature = crypto
.createHmac('sha256', secret)
.update(payloadString, 'utf8')
.digest('hex');
return {
headers: {
'Typeform-Signature': `sha256=${signature}`,
'Content-Type': 'application/json',
},
body: payloadString,
};
}
// Test your endpoint
const testPayload = {
event_id: 'test_123',
event_type: 'form_response',
form_response: {
/* ... */
},
};
const testWebhook = createTestWebhook(testPayload, 'your_secret');
fetch('http://localhost:3000/webhooks/typeform', {
method: 'POST',
headers: testWebhook.headers,
body: testWebhook.body,
});

Unit test example

const assert = require('assert');
describe('Webhook signature verification', () => {
const secret = 'test_secret';
it('should accept valid signatures', () => {
const payload = JSON.stringify({ test: 'data' });
const signature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
const request = {
headers: { 'typeform-signature': `sha256=${signature}` },
body: payload,
};
assert.strictEqual(verifyWebhookSignature(request, secret), true);
});
it('should reject invalid signatures', () => {
const request = {
headers: { 'typeform-signature': 'sha256=invalid' },
body: JSON.stringify({ test: 'data' }),
};
assert.strictEqual(verifyWebhookSignature(request, secret), false);
});
it('should reject missing signatures', () => {
const request = {
headers: {},
body: JSON.stringify({ test: 'data' }),
};
assert.strictEqual(verifyWebhookSignature(request, secret), false);
});
});

Rotating secrets

Best practice is to rotate webhook secrets periodically:

# Update webhook with new secret
curl -X PUT \
'https://api.typeform.com/forms/{form_id}/webhooks/{tag}' \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"url": "https://your-server.com/webhooks/typeform",
"enabled": true,
"secret": "your_new_secret"
}'

During rotation, implement a grace period:

function verifyWebhookSignature(request, currentSecret, previousSecret = null) {
// Try current secret
if (verifySignature(request, currentSecret)) {
return true;
}
// During rotation, also accept previous secret
if (previousSecret && verifySignature(request, previousSecret)) {
console.warn('Webhook verified with old secret - update in progress');
return true;
}
return false;
}

Security best practices

  1. Always verify signatures: Never skip verification in production
  2. Use HTTPS: Webhooks require HTTPS endpoints
  3. Store secrets securely: Use environment variables or secret managers
  4. Rotate secrets regularly: Change secrets periodically
  5. Log verification failures: Monitor for potential attacks
  6. Rate limit your endpoint: Prevent abuse
  7. Validate payload structure: Check that the payload is well-formed
  8. Use timing-safe comparisons: Prevent timing attacks

Troubleshooting

Signature verification always fails

  1. Check you’re using the raw body: Don’t verify against parsed JSON
  2. Verify the secret: Ensure you’re using the correct secret
  3. Check character encoding: Use UTF-8 encoding
  4. Inspect the header name: Should be Typeform-Signature (case-sensitive)
  5. Check signature format: Should be sha256=<hex_string>

Debugging signatures

function debugSignature(request, secret) {
console.log('Headers:', request.headers);
console.log('Body:', request.body);
console.log('Secret:', secret ? '(present)' : '(missing)');
const signature = request.headers['typeform-signature'];
console.log('Received signature:', signature);
const expected = crypto
.createHmac('sha256', secret)
.update(request.body, 'utf8')
.digest('hex');
console.log('Expected signature:', expected);
console.log('Match:', signature === `sha256=${expected}`);
}

Next steps