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
- Always use HTTPS - Never expose webhooks over HTTP
- Implement signature verification - HMAC is the gold standard
- Validate all input - Never trust incoming data
- Rate limit aggressively - Protect against abuse
- Log security events - Enable forensic analysis
- Use short-lived credentials - Rotate secrets regularly
- Implement replay protection - Use nonces and timestamps
- Filter by IP when possible - Reduce attack surface
Following these patterns will significantly improve the security posture of your n8n webhook integrations.