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:

  1. Unique timestamp: Each delivery includes X-Docutray-Timestamp (Unix seconds)
  2. 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;
}

On this page