n8n Automation

n8n Webhook Security Best Practices: Authentication, Validation, and Rate Limiting

DeviDevs Team
12 min read
#n8n#webhooks#security#authentication#automation

n8n Webhook Security Best Practices: Authentication, Validation, and Rate Limiting

Webhooks are powerful entry points for automation workflows, but they also present security risks if not properly secured. This guide covers comprehensive security measures for n8n webhooks.

Authentication Methods

HMAC Signature Verification

The most robust method for webhook authentication:

// Function node for HMAC verification
const crypto = require('crypto');
 
const secret = $env.WEBHOOK_SECRET;
const payload = JSON.stringify($json);
const signature = $request.headers['x-signature-256'];
 
if (!signature) {
  throw new Error('Missing signature header');
}
 
// Compute expected signature
const expectedSignature = 'sha256=' + crypto
  .createHmac('sha256', secret)
  .update(payload, 'utf8')
  .digest('hex');
 
// Constant-time comparison to prevent timing attacks
const isValid = crypto.timingSafeEqual(
  Buffer.from(signature),
  Buffer.from(expectedSignature)
);
 
if (!isValid) {
  throw new Error('Invalid signature');
}
 
return [{
  json: {
    ...$json,
    _verified: true,
    _timestamp: new Date().toISOString()
  }
}];

Header-Based API Key Authentication

// Function node for API key validation
const apiKey = $request.headers['x-api-key'];
const validApiKeys = $env.VALID_API_KEYS.split(',');
 
if (!apiKey) {
  return [{
    json: {
      error: 'Missing API key',
      status: 401
    }
  }];
}
 
// Hash the provided key for comparison (if storing hashed keys)
const crypto = require('crypto');
const hashedKey = crypto
  .createHash('sha256')
  .update(apiKey)
  .digest('hex');
 
const isValid = validApiKeys.includes(hashedKey);
 
if (!isValid) {
  // Log failed attempt
  console.log(`Invalid API key attempt from ${$request.headers['x-forwarded-for']}`);
 
  return [{
    json: {
      error: 'Invalid API key',
      status: 401
    }
  }];
}
 
return [{
  json: {
    ...$json,
    _authenticated: true,
    _keyId: hashedKey.substring(0, 8) // Partial hash for logging
  }
}];

OAuth 2.0 Bearer Token Validation

// Function node for OAuth token validation
const authHeader = $request.headers['authorization'];
 
if (!authHeader || !authHeader.startsWith('Bearer ')) {
  throw new Error('Missing or invalid authorization header');
}
 
const token = authHeader.split(' ')[1];
 
// Validate token with OAuth provider
const https = require('https');
 
const validateToken = () => {
  return new Promise((resolve, reject) => {
    const options = {
      hostname: 'oauth.provider.com',
      path: '/oauth/introspect',
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Authorization': `Basic ${Buffer.from($env.OAUTH_CLIENT_ID + ':' + $env.OAUTH_CLIENT_SECRET).toString('base64')}`
      }
    };
 
    const req = https.request(options, (res) => {
      let data = '';
      res.on('data', chunk => data += chunk);
      res.on('end', () => resolve(JSON.parse(data)));
    });
 
    req.on('error', reject);
    req.write(`token=${token}`);
    req.end();
  });
};
 
const tokenInfo = await validateToken();
 
if (!tokenInfo.active) {
  throw new Error('Token is invalid or expired');
}
 
// Check required scopes
const requiredScopes = ['webhook:write', 'data:read'];
const tokenScopes = tokenInfo.scope.split(' ');
const hasAllScopes = requiredScopes.every(s => tokenScopes.includes(s));
 
if (!hasAllScopes) {
  throw new Error('Insufficient permissions');
}
 
return [{
  json: {
    ...$json,
    _user: tokenInfo.sub,
    _scopes: tokenScopes
  }
}];

JWT Token Validation

// Function node for JWT validation
const jwt = require('jsonwebtoken');
 
const authHeader = $request.headers['authorization'];
if (!authHeader?.startsWith('Bearer ')) {
  throw new Error('Missing authorization header');
}
 
const token = authHeader.split(' ')[1];
 
try {
  // Verify JWT with public key
  const decoded = jwt.verify(token, $env.JWT_PUBLIC_KEY, {
    algorithms: ['RS256'],
    issuer: 'https://auth.example.com',
    audience: 'webhook-api'
  });
 
  // Check token expiration
  const now = Math.floor(Date.now() / 1000);
  if (decoded.exp < now) {
    throw new Error('Token expired');
  }
 
  // Check required claims
  if (!decoded.permissions?.includes('webhook:invoke')) {
    throw new Error('Missing required permission');
  }
 
  return [{
    json: {
      ...$json,
      _user: decoded.sub,
      _permissions: decoded.permissions,
      _tokenId: decoded.jti
    }
  }];
} catch (error) {
  throw new Error(`JWT validation failed: ${error.message}`);
}

Payload Validation

Schema Validation with JSON Schema

// Function node for JSON schema validation
const Ajv = require('ajv');
const addFormats = require('ajv-formats');
 
const ajv = new Ajv({ allErrors: true });
addFormats(ajv);
 
// Define expected schema
const schema = {
  type: 'object',
  required: ['event', 'data', 'timestamp'],
  properties: {
    event: {
      type: 'string',
      enum: ['order.created', 'order.updated', 'order.cancelled']
    },
    data: {
      type: 'object',
      required: ['orderId', 'customerId'],
      properties: {
        orderId: { type: 'string', pattern: '^ORD-[A-Z0-9]{8}$' },
        customerId: { type: 'string', format: 'uuid' },
        amount: { type: 'number', minimum: 0 },
        currency: { type: 'string', pattern: '^[A-Z]{3}$' },
        items: {
          type: 'array',
          minItems: 1,
          items: {
            type: 'object',
            required: ['sku', 'quantity'],
            properties: {
              sku: { type: 'string' },
              quantity: { type: 'integer', minimum: 1 },
              price: { type: 'number', minimum: 0 }
            }
          }
        }
      }
    },
    timestamp: { type: 'string', format: 'date-time' }
  },
  additionalProperties: false
};
 
const validate = ajv.compile(schema);
const valid = validate($json);
 
if (!valid) {
  const errors = validate.errors.map(e => ({
    path: e.instancePath,
    message: e.message,
    params: e.params
  }));
 
  return [{
    json: {
      valid: false,
      errors,
      status: 400
    }
  }];
}
 
return [{
  json: {
    ...$json,
    _validated: true
  }
}];

Input Sanitization

// Function node for input sanitization
const sanitizeHtml = require('sanitize-html');
const validator = require('validator');
 
function sanitizeValue(value, fieldType) {
  if (value === null || value === undefined) {
    return value;
  }
 
  switch (fieldType) {
    case 'string':
      // Remove HTML and dangerous characters
      return sanitizeHtml(String(value), {
        allowedTags: [],
        allowedAttributes: {}
      }).trim();
 
    case 'email':
      if (!validator.isEmail(String(value))) {
        throw new Error(`Invalid email: ${value}`);
      }
      return validator.normalizeEmail(String(value));
 
    case 'url':
      if (!validator.isURL(String(value), { protocols: ['https'] })) {
        throw new Error(`Invalid URL: ${value}`);
      }
      return value;
 
    case 'integer':
      const num = parseInt(value, 10);
      if (isNaN(num)) {
        throw new Error(`Invalid integer: ${value}`);
      }
      return num;
 
    case 'uuid':
      if (!validator.isUUID(String(value))) {
        throw new Error(`Invalid UUID: ${value}`);
      }
      return String(value).toLowerCase();
 
    default:
      return value;
  }
}
 
// Define field types for sanitization
const fieldTypes = {
  'data.customerId': 'uuid',
  'data.email': 'email',
  'data.website': 'url',
  'data.description': 'string',
  'data.quantity': 'integer'
};
 
function sanitizeObject(obj, prefix = '') {
  const sanitized = {};
 
  for (const [key, value] of Object.entries(obj)) {
    const fullPath = prefix ? `${prefix}.${key}` : key;
 
    if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
      sanitized[key] = sanitizeObject(value, fullPath);
    } else if (Array.isArray(value)) {
      sanitized[key] = value.map((item, index) =>
        typeof item === 'object' ? sanitizeObject(item, `${fullPath}[${index}]`) : item
      );
    } else {
      const fieldType = fieldTypes[fullPath] || 'string';
      sanitized[key] = sanitizeValue(value, fieldType);
    }
  }
 
  return sanitized;
}
 
try {
  const sanitizedPayload = sanitizeObject($json);
  return [{ json: sanitizedPayload }];
} catch (error) {
  return [{
    json: {
      error: error.message,
      status: 400
    }
  }];
}

Rate Limiting

Token Bucket Rate Limiter

// Function node for rate limiting
const Redis = require('ioredis');
const redis = new Redis($env.REDIS_URL);
 
const clientId = $request.headers['x-client-id'] ||
                 $request.headers['x-forwarded-for'] ||
                 'anonymous';
 
const config = {
  bucketSize: 100,      // Max tokens
  refillRate: 10,       // Tokens per second
  requestCost: 1        // Tokens per request
};
 
const key = `ratelimit:${clientId}`;
 
// Lua script for atomic token bucket
const luaScript = `
  local key = KEYS[1]
  local now = tonumber(ARGV[1])
  local bucketSize = tonumber(ARGV[2])
  local refillRate = tonumber(ARGV[3])
  local requestCost = tonumber(ARGV[4])
  local ttl = tonumber(ARGV[5])
 
  local bucket = redis.call('HMGET', key, 'tokens', 'lastRefill')
  local tokens = tonumber(bucket[1]) or bucketSize
  local lastRefill = tonumber(bucket[2]) or now
 
  -- Calculate token refill
  local elapsed = now - lastRefill
  local refill = elapsed * refillRate
  tokens = math.min(bucketSize, tokens + refill)
 
  -- Check if request can proceed
  if tokens >= requestCost then
    tokens = tokens - requestCost
    redis.call('HMSET', key, 'tokens', tokens, 'lastRefill', now)
    redis.call('EXPIRE', key, ttl)
    return {1, tokens, 0}  -- allowed, remaining, retryAfter
  else
    local retryAfter = (requestCost - tokens) / refillRate
    return {0, tokens, retryAfter}  -- denied, remaining, retryAfter
  end
`;
 
const now = Date.now() / 1000;
const result = await redis.eval(
  luaScript,
  1,
  key,
  now,
  config.bucketSize,
  config.refillRate,
  config.requestCost,
  3600  // TTL in seconds
);
 
const [allowed, remaining, retryAfter] = result;
 
if (!allowed) {
  return [{
    json: {
      error: 'Rate limit exceeded',
      status: 429,
      retryAfter: Math.ceil(retryAfter),
      headers: {
        'X-RateLimit-Limit': config.bucketSize,
        'X-RateLimit-Remaining': Math.floor(remaining),
        'X-RateLimit-Reset': Math.ceil(now + retryAfter),
        'Retry-After': Math.ceil(retryAfter)
      }
    }
  }];
}
 
return [{
  json: {
    ...$json,
    _rateLimit: {
      remaining: Math.floor(remaining),
      limit: config.bucketSize
    }
  }
}];

Sliding Window Rate Limiter

// Function node for sliding window rate limit
const Redis = require('ioredis');
const redis = new Redis($env.REDIS_URL);
 
const clientId = $request.headers['x-api-key'] ||
                 $request.headers['x-forwarded-for'];
 
const windowMs = 60000;  // 1 minute window
const maxRequests = 60;  // 60 requests per minute
 
const key = `ratelimit:sliding:${clientId}`;
const now = Date.now();
const windowStart = now - windowMs;
 
// Use sorted set with timestamps
const multi = redis.multi();
 
// Remove old entries outside the window
multi.zremrangebyscore(key, '-inf', windowStart);
 
// Add current request
multi.zadd(key, now, `${now}-${Math.random()}`);
 
// Count requests in window
multi.zcard(key);
 
// Set expiry
multi.expire(key, Math.ceil(windowMs / 1000) + 1);
 
const results = await multi.exec();
const requestCount = results[2][1];
 
if (requestCount > maxRequests) {
  // Get oldest request timestamp to calculate reset time
  const oldestInWindow = await redis.zrange(key, 0, 0, 'WITHSCORES');
  const resetTime = oldestInWindow.length > 1
    ? parseInt(oldestInWindow[1]) + windowMs
    : now + windowMs;
 
  return [{
    json: {
      error: 'Rate limit exceeded',
      status: 429,
      limit: maxRequests,
      remaining: 0,
      resetAt: new Date(resetTime).toISOString()
    }
  }];
}
 
return [{
  json: {
    ...$json,
    _rateLimit: {
      limit: maxRequests,
      remaining: maxRequests - requestCount,
      windowMs
    }
  }
}];

IP Filtering

IP Whitelist/Blacklist

// Function node for IP filtering
const ipaddr = require('ipaddr.js');
 
// Get client IP
const clientIp = $request.headers['x-forwarded-for']?.split(',')[0].trim() ||
                 $request.headers['x-real-ip'] ||
                 $request.connection?.remoteAddress;
 
// Configuration
const config = {
  mode: 'whitelist',  // 'whitelist' or 'blacklist'
  whitelist: [
    '10.0.0.0/8',
    '172.16.0.0/12',
    '192.168.0.0/16',
    '203.0.113.50',        // Specific IP
    '2001:db8::/32'        // IPv6 range
  ],
  blacklist: [
    '0.0.0.0/8',
    '224.0.0.0/4',
    '169.254.0.0/16'
  ]
};
 
function isIpInRange(ip, ranges) {
  try {
    const addr = ipaddr.parse(ip);
 
    for (const range of ranges) {
      if (range.includes('/')) {
        // CIDR notation
        const [network, bits] = range.split('/');
        const networkAddr = ipaddr.parse(network);
 
        if (addr.kind() === networkAddr.kind()) {
          if (addr.match(networkAddr, parseInt(bits))) {
            return true;
          }
        }
      } else {
        // Single IP
        if (ipaddr.parse(range).toString() === addr.toString()) {
          return true;
        }
      }
    }
    return false;
  } catch (error) {
    console.error(`Invalid IP address: ${ip}`);
    return false;
  }
}
 
let allowed = false;
 
if (config.mode === 'whitelist') {
  allowed = isIpInRange(clientIp, config.whitelist);
} else {
  allowed = !isIpInRange(clientIp, config.blacklist);
}
 
if (!allowed) {
  // Log blocked attempt
  console.log(`Blocked request from IP: ${clientIp}`);
 
  return [{
    json: {
      error: 'Access denied',
      status: 403
    }
  }];
}
 
return [{
  json: {
    ...$json,
    _clientIp: clientIp,
    _ipVerified: true
  }
}];

Geographic IP Filtering

// Function node for geo-based IP filtering
const clientIp = $request.headers['x-forwarded-for']?.split(',')[0].trim();
 
// Using a geolocation service
const https = require('https');
 
const geoLookup = (ip) => {
  return new Promise((resolve, reject) => {
    https.get(`https://ipapi.co/${ip}/json/`, (res) => {
      let data = '';
      res.on('data', chunk => data += chunk);
      res.on('end', () => {
        try {
          resolve(JSON.parse(data));
        } catch (e) {
          reject(e);
        }
      });
    }).on('error', reject);
  });
};
 
const allowedCountries = ['US', 'CA', 'GB', 'DE', 'FR'];
const blockedRegions = ['CN', 'RU', 'KP'];
 
try {
  const geoData = await geoLookup(clientIp);
 
  if (blockedRegions.includes(geoData.country_code)) {
    return [{
      json: {
        error: 'Access denied from this region',
        status: 403
      }
    }];
  }
 
  // Optional: Require specific countries
  // if (!allowedCountries.includes(geoData.country_code)) {
  //   return [{ json: { error: 'Access denied', status: 403 } }];
  // }
 
  return [{
    json: {
      ...$json,
      _geo: {
        country: geoData.country_code,
        region: geoData.region,
        city: geoData.city
      }
    }
  }];
} catch (error) {
  // Allow on geo lookup failure (fail open) or deny (fail closed)
  console.error(`Geo lookup failed: ${error.message}`);
  return [{ json: $json }];  // Fail open
}

Request Replay Prevention

Nonce and Timestamp Validation

// Function node for replay attack prevention
const Redis = require('ioredis');
const redis = new Redis($env.REDIS_URL);
 
const timestamp = $request.headers['x-timestamp'];
const nonce = $request.headers['x-nonce'];
 
// Configuration
const config = {
  maxTimestampSkew: 300,  // 5 minutes
  nonceExpirySeconds: 600  // 10 minutes
};
 
// Validate timestamp
if (!timestamp) {
  throw new Error('Missing timestamp header');
}
 
const requestTime = parseInt(timestamp);
const now = Math.floor(Date.now() / 1000);
const timeDiff = Math.abs(now - requestTime);
 
if (timeDiff > config.maxTimestampSkew) {
  return [{
    json: {
      error: 'Request timestamp too old or too far in future',
      status: 400
    }
  }];
}
 
// Validate nonce
if (!nonce) {
  throw new Error('Missing nonce header');
}
 
// Check if nonce was already used
const nonceKey = `nonce:${nonce}`;
const nonceExists = await redis.exists(nonceKey);
 
if (nonceExists) {
  return [{
    json: {
      error: 'Duplicate request detected',
      status: 409
    }
  }];
}
 
// Store nonce to prevent replay
await redis.setex(nonceKey, config.nonceExpirySeconds, '1');
 
return [{
  json: {
    ...$json,
    _requestId: nonce,
    _timestamp: requestTime
  }
}];

Complete Security Pipeline

Combining All Security Measures

// Main webhook security pipeline - Function node
const securityPipeline = {
  async validateIp(request) {
    // IP filtering logic
    const clientIp = request.headers['x-forwarded-for']?.split(',')[0].trim();
    // ... IP validation
    return { valid: true, clientIp };
  },
 
  async validateAuth(request) {
    // Authentication logic
    const signature = request.headers['x-signature-256'];
    // ... HMAC validation
    return { valid: true };
  },
 
  async validateRateLimit(clientId) {
    // Rate limiting logic
    // ... Token bucket or sliding window
    return { allowed: true, remaining: 50 };
  },
 
  async validatePayload(payload, schema) {
    // Schema validation
    // ... JSON Schema validation
    return { valid: true };
  },
 
  async validateReplay(request) {
    // Replay prevention
    // ... Nonce validation
    return { valid: true };
  }
};
 
// Execute pipeline
const results = {
  ip: null,
  auth: null,
  rateLimit: null,
  payload: null,
  replay: null
};
 
try {
  // Step 1: IP Filtering
  results.ip = await securityPipeline.validateIp($request);
  if (!results.ip.valid) {
    throw new Error('IP validation failed');
  }
 
  // Step 2: Authentication
  results.auth = await securityPipeline.validateAuth($request);
  if (!results.auth.valid) {
    throw new Error('Authentication failed');
  }
 
  // Step 3: Rate Limiting
  const clientId = results.auth.clientId || results.ip.clientIp;
  results.rateLimit = await securityPipeline.validateRateLimit(clientId);
  if (!results.rateLimit.allowed) {
    throw new Error('Rate limit exceeded');
  }
 
  // Step 4: Replay Prevention
  results.replay = await securityPipeline.validateReplay($request);
  if (!results.replay.valid) {
    throw new Error('Replay attack detected');
  }
 
  // Step 5: Payload Validation
  results.payload = await securityPipeline.validatePayload($json, schema);
  if (!results.payload.valid) {
    throw new Error('Invalid payload');
  }
 
  // All checks passed
  return [{
    json: {
      ...$json,
      _security: {
        clientIp: results.ip.clientIp,
        authenticated: true,
        rateLimitRemaining: results.rateLimit.remaining,
        validatedAt: new Date().toISOString()
      }
    }
  }];
 
} catch (error) {
  // Log security event
  console.error(`Security check failed: ${error.message}`, {
    ip: results.ip?.clientIp,
    step: Object.keys(results).find(k => results[k] === null) || 'unknown'
  });
 
  return [{
    json: {
      error: error.message,
      status: error.message.includes('Rate limit') ? 429 : 403
    }
  }];
}

Best Practices Summary

  1. Always use HTTPS - Never expose webhooks over HTTP
  2. Implement signature verification - HMAC is the gold standard
  3. Validate all input - Never trust incoming data
  4. Rate limit aggressively - Protect against abuse
  5. Log security events - Enable forensic analysis
  6. Use short-lived credentials - Rotate secrets regularly
  7. Implement replay protection - Use nonces and timestamps
  8. Filter by IP when possible - Reduce attack surface

Following these patterns will significantly improve the security posture of your n8n webhook integrations.

Weekly AI Security & Automation Digest

Get the latest on AI Security, workflow automation, secure integrations, and custom platform development delivered weekly.

No spam. Unsubscribe anytime.