Security and Signature Verification
Signature verification methods for webhooks and replay attack protection
Security and Signature Verification
Docutray provides two signature verification methods to adapt to different architectures:
Method 1: Body-based signature (Traditional)
Validates the complete payload content. Ideal for traditional implementations:
const crypto = require('crypto');
function verifyWebhookBody(bodyString, signature, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(bodyString)
.digest('hex');
return `sha256=${expectedSignature}` === signature;
}
// Usage in Express.js
app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => {
const signature = req.headers['x-docutray-signature'];
const secret = process.env.WEBHOOK_SECRET;
if (!verifyWebhookBody(req.body.toString(), signature, secret)) {
return res.status(401).send('Invalid signature');
}
// Process webhook
const payload = JSON.parse(req.body);
// ...
});Method 2: Authentication signature (Lambda Authorizers compatible)
Validates using only metadata in headers, without body access. Ideal for AWS Lambda Authorizers, Azure Functions, or Google Cloud Functions:
const crypto = require('crypto');
function verifyWebhookAuth(headers, webhookUrl, secret) {
const authSignature = headers['x-docutray-auth-signature'];
const timestamp = headers['x-docutray-timestamp'];
const requestId = headers['x-docutray-request-id'];
const eventType = headers['x-docutray-event'];
// Validate timestamp (5-minute window)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
return false; // Timestamp expired
}
// Calculate expected signature
const authPayload = `${requestId}|${timestamp}|${webhookUrl}|${eventType}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(authPayload)
.digest('hex');
return `sha256=${expectedSignature}` === authSignature;
}Complete example: AWS Lambda Authorizer
// Lambda Authorizer for AWS API Gateway
exports.handler = async (event) => {
const crypto = require('crypto');
try {
// Extract headers
const authSignature = event.headers['x-docutray-auth-signature'];
const timestamp = parseInt(event.headers['x-docutray-timestamp']);
const requestId = event.headers['x-docutray-request-id'];
const eventType = event.headers['x-docutray-event'];
const webhookUrl = `https://${event.headers.host}${event.path}`;
// Validate header presence
if (!authSignature || !timestamp || !requestId || !eventType) {
return generatePolicy('user', 'Deny', event.methodArn);
}
// Validate timestamp (5-minute window)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
console.log('Webhook timestamp expired');
return generatePolicy('user', 'Deny', event.methodArn);
}
// Recalculate expected signature
const secret = process.env.DOCUTRAY_WEBHOOK_SECRET;
const authPayload = `${requestId}|${timestamp}|${webhookUrl}|${eventType}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(authPayload)
.digest('hex');
// Validate signature
if (`sha256=${expectedSignature}` !== authSignature) {
console.log('Signature verification failed');
return generatePolicy('user', 'Deny', event.methodArn);
}
// Valid signature - allow request
return generatePolicy('user', 'Allow', event.methodArn);
} catch (error) {
console.error('Error in authorizer:', error);
return generatePolicy('user', 'Deny', event.methodArn);
}
};
function generatePolicy(principalId, effect, resource) {
return {
principalId,
policyDocument: {
Version: '2012-10-17',
Statement: [{
Action: 'execute-api:Invoke',
Effect: effect,
Resource: resource
}]
}
};
}Example: Python for AWS Lambda Authorizer
import hmac
import hashlib
import time
import os
def lambda_handler(event, context):
try:
# Extract headers
headers = {k.lower(): v for k, v in event['headers'].items()}
auth_signature = headers.get('x-docutray-auth-signature')
timestamp = int(headers.get('x-docutray-timestamp', 0))
request_id = headers.get('x-docutray-request-id')
event_type = headers.get('x-docutray-event')
webhook_url = f"https://{headers['host']}{event['path']}"
# Validate header presence
if not all([auth_signature, timestamp, request_id, event_type]):
return generate_policy('user', 'Deny', event['methodArn'])
# Validate timestamp (5-minute window)
now = int(time.time())
if abs(now - timestamp) > 300:
print('Webhook timestamp expired')
return generate_policy('user', 'Deny', event['methodArn'])
# Recalculate expected signature
secret = os.environ['DOCUTRAY_WEBHOOK_SECRET']
auth_payload = f"{request_id}|{timestamp}|{webhook_url}|{event_type}"
expected_signature = hmac.new(
secret.encode(),
auth_payload.encode(),
hashlib.sha256
).hexdigest()
# Validate signature
if f"sha256={expected_signature}" != auth_signature:
print('Signature verification failed')
return generate_policy('user', 'Deny', event['methodArn'])
# Valid signature - allow request
return generate_policy('user', 'Allow', event['methodArn'])
except Exception as error:
print(f'Error in authorizer: {error}')
return generate_policy('user', 'Deny', event['methodArn'])
def generate_policy(principal_id, effect, resource):
return {
'principalId': principal_id,
'policyDocument': {
'Version': '2012-10-17',
'Statement': [{
'Action': 'execute-api:Invoke',
'Effect': effect,
'Resource': resource
}]
}
}Replay attack protection
The system includes automatic replay attack protection through:
- Unique timestamp: Each delivery includes
X-Docutray-Timestamp(Unix seconds) - Unique Request ID: Each delivery has a unique
X-Docutray-Request-Id(UUID)
Security recommendations
- Validate timestamp: Reject requests with timestamps outside a reasonable window (recommended: 5 minutes)
- Cache request-id: Temporarily store processed request-ids to detect duplicates
- Use HTTPS: Always use HTTPS endpoints to prevent interception
// Example of request-id cache with Redis
const redis = require('redis');
const client = redis.createClient();
async function isReplayAttack(requestId) {
const key = `webhook:${requestId}`;
const exists = await client.exists(key);
if (exists) {
return true; // Request-id already processed
}
// Mark as processed (10-minute TTL)
await client.setex(key, 600, '1');
return false;
}