Webhook Security
Learn how to secure your webhook endpoints using signature verification, prevent replay attacks, and implement security best practices.
Overviewโ
Webhook security is critical to ensure that webhook events received by your endpoint are authentic and sent by Omise. This guide covers:
- HMAC-SHA256 signature verification
- Timing-safe string comparison
- Webhook signing key management
- Secret rotation procedures
- Replay attack prevention
- Security best practices and common pitfalls
Signature Verificationโ
Omise signs all webhook requests using HMAC-SHA256 with your webhook signing key. The signature is included in the X-Omise-Signature HTTP header.
How Signature Verification Worksโ
- Omise generates an HMAC-SHA256 hash of the raw request body using your webhook signing key
- The signature is sent in the
X-Omise-Signatureheader - Your server computes the same HMAC-SHA256 hash using the raw request body
- Compare the computed signature with the received signature
- Process the webhook only if signatures match
Signature Verification Flowโ
โโโโโโโโโโโโโโโ
โ Omise โ
โ Server โ
โโโโโโโโฌโโโโโโโ
โ
โ 1. Generate HMAC-SHA256(webhook_key, payload)
โ 2. Add signature to X-Omise-Signature header
โ 3. POST request to your endpoint
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Your Webhook Endpoint โ
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโ
โ
โ 4. Extract signature from header
โ 5. Compute HMAC-SHA256(webhook_key, raw_body)
โ 6. Compare signatures (timing-safe)
โ
โผ
โโโโโโโโโโโ
โ Match? โ
โโโโโโฌโโโโโ
โ
โโโโโโโโดโโโโโโโ
โ โ
Yes No
โ โ
โผ โผ
Process Reject (401)
Webhook
Implementation Examplesโ
Node.jsโ
Complete signature verification implementation:
const express = require('express');
const crypto = require('crypto');
const app = express();
// Important: Use express.raw() to preserve raw body
app.use('/webhooks/omise', express.raw({ type: 'application/json' }));
// Store webhook key securely in environment variables
const WEBHOOK_KEY = process.env.OMISE_WEBHOOK_KEY;
function verifySignature(rawBody, signature) {
// Compute HMAC-SHA256 hash
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_KEY)
.update(rawBody)
.digest('hex');
// Timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
app.post('/webhooks/omise', (req, res) => {
const signature = req.headers['x-omise-signature'];
const rawBody = req.body;
// Verify signature
if (!signature || !verifySignature(rawBody, signature)) {
console.error('Invalid webhook signature');
return res.status(401).json({ error: 'Invalid signature' });
}
// Parse JSON after verification
const event = JSON.parse(rawBody.toString());
// Respond immediately
res.status(200).json({ received: true });
// Process webhook asynchronously
processWebhook(event).catch(err => {
console.error('Webhook processing error:', err);
});
});
async function processWebhook(event) {
console.log(`Processing event: ${event.key}`);
// Your webhook processing logic
}
app.listen(3000, () => {
console.log('Webhook server running on port 3000');
});
Node.js with Express JSON Parserโ
If you need to use express.json(), store the raw body:
const express = require('express');
const crypto = require('crypto');
const app = express();
// Middleware to capture raw body
app.use('/webhooks/omise', (req, res, next) => {
let rawBody = '';
req.on('data', chunk => {
rawBody += chunk.toString();
});
req.on('end', () => {
req.rawBody = rawBody;
req.body = JSON.parse(rawBody);
next();
});
});
function verifySignature(rawBody, signature) {
const expectedSignature = crypto
.createHmac('sha256', process.env.OMISE_WEBHOOK_KEY)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
app.post('/webhooks/omise', (req, res) => {
const signature = req.headers['x-omise-signature'];
if (!signature || !verifySignature(req.rawBody, signature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
res.status(200).json({ received: true });
processWebhook(req.body);
});
Python (Flask)โ
from flask import Flask, request, jsonify
import hmac
import hashlib
import os
app = Flask(__name__)
# Store webhook key securely
WEBHOOK_KEY = os.environ.get('OMISE_WEBHOOK_KEY')
def verify_signature(payload, signature):
"""
Verify webhook signature using HMAC-SHA256
Args:
payload: Raw request body (bytes or string)
signature: Signature from X-Omise-Signature header
Returns:
bool: True if signature is valid
"""
if isinstance(payload, str):
payload = payload.encode('utf-8')
# Compute expected signature
expected_signature = hmac.new(
WEBHOOK_KEY.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
# Timing-safe comparison
return hmac.compare_digest(signature, expected_signature)
@app.route('/webhooks/omise', methods=['POST'])
def handle_webhook():
# Get raw body and signature
payload = request.get_data()
signature = request.headers.get('X-Omise-Signature')
# Verify signature
if not signature or not verify_signature(payload, signature):
app.logger.error('Invalid webhook signature')
return jsonify({'error': 'Invalid signature'}), 401
# Parse JSON after verification
event = request.get_json()
# Respond immediately
response = jsonify({'received': True})
# Process webhook asynchronously
process_webhook_async(event)
return response, 200
def process_webhook_async(event):
"""Process webhook in background"""
print(f"Processing event: {event['key']}")
# Your webhook processing logic
if __name__ == '__main__':
app.run(port=3000)
Python (Django)โ
from django.http import JsonResponse, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
import hmac
import hashlib
import json
import os
import logging
logger = logging.getLogger(__name__)
WEBHOOK_KEY = os.environ.get('OMISE_WEBHOOK_KEY')
def verify_signature(payload, signature):
"""Verify HMAC-SHA256 signature"""
if isinstance(payload, str):
payload = payload.encode('utf-8')
expected_signature = hmac.new(
WEBHOOK_KEY.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected_signature)
@csrf_exempt
@require_http_methods(["POST"])
def omise_webhook(request):
# Get raw body and signature
payload = request.body
signature = request.META.get('HTTP_X_OMISE_SIGNATURE')
# Verify signature
if not signature or not verify_signature(payload, signature):
logger.error('Invalid webhook signature')
return JsonResponse({'error': 'Invalid signature'}, status=401)
# Parse event
try:
event = json.loads(payload)
except json.JSONDecodeError:
logger.error('Invalid JSON payload')
return JsonResponse({'error': 'Invalid JSON'}, status=400)
# Respond immediately
response = JsonResponse({'received': True})
# Process asynchronously using Celery
from .tasks import process_webhook
process_webhook.delay(event)
return response
Ruby (Sinatra)โ
require 'sinatra'
require 'json'
require 'openssl'
# Store webhook key securely
WEBHOOK_KEY = ENV['OMISE_WEBHOOK_KEY']
def verify_signature(payload, signature)
# Compute expected signature
expected_signature = OpenSSL::HMAC.hexdigest(
OpenSSL::Digest.new('sha256'),
WEBHOOK_KEY,
payload
)
# Timing-safe comparison
Rack::Utils.secure_compare(signature, expected_signature)
end
post '/webhooks/omise' do
# Get raw body and signature
payload = request.body.read
signature = request.env['HTTP_X_OMISE_SIGNATURE']
# Verify signature
unless signature && verify_signature(payload, signature)
logger.error 'Invalid webhook signature'
halt 401, { error: 'Invalid signature' }.to_json
end
# Parse event
event = JSON.parse(payload)
# Respond immediately
status 200
content_type :json
response = { received: true }.to_json
# Process asynchronously
Thread.new do
process_webhook(event)
end
response
end
def process_webhook(event)
puts "Processing event: #{event['key']}"
# Your webhook processing logic
end
Ruby (Rails)โ
# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
WEBHOOK_KEY = ENV['OMISE_WEBHOOK_KEY']
def omise
# Get raw body and signature
payload = request.raw_post
signature = request.headers['X-Omise-Signature']
# Verify signature
unless signature && verify_signature(payload, signature)
Rails.logger.error 'Invalid webhook signature'
render json: { error: 'Invalid signature' }, status: :unauthorized
return
end
# Parse event
event = JSON.parse(payload)
# Respond immediately
render json: { received: true }, status: :ok
# Process asynchronously using ActiveJob
WebhookProcessorJob.perform_later(event)
end
private
def verify_signature(payload, signature)
expected_signature = OpenSSL::HMAC.hexdigest(
OpenSSL::Digest.new('sha256'),
WEBHOOK_KEY,
payload
)
ActiveSupport::SecurityUtils.secure_compare(signature, expected_signature)
end
end
# config/routes.rb
Rails.application.routes.draw do
post 'webhooks/omise', to: 'webhooks#omise'
end
# app/jobs/webhook_processor_job.rb
class WebhookProcessorJob < ApplicationJob
queue_as :webhooks
def perform(event)
Rails.logger.info "Processing webhook: #{event['key']}"
case event['key']
when 'charge.complete'
ChargeCompleteHandler.new(event['data']).process
when 'refund.create'
RefundCreateHandler.new(event['data']).process
# Add more handlers
end
end
end
PHPโ
<?php
// webhook.php
// Get webhook key from environment
$webhookKey = getenv('OMISE_WEBHOOK_KEY');
function verifySignature($payload, $signature, $key) {
// Compute expected signature
$expectedSignature = hash_hmac('sha256', $payload, $key);
// Timing-safe comparison
return hash_equals($signature, $expectedSignature);
}
// Get raw POST body
$payload = file_get_contents('php://input');
// Get signature from header
$signature = $_SERVER['HTTP_X_OMISE_SIGNATURE'] ?? '';
// Verify signature
if (empty($signature) || !verifySignature($payload, $signature, $webhookKey)) {
error_log('Invalid webhook signature');
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'Invalid signature']);
exit;
}
// Parse event
$event = json_decode($payload, true);
if (json_last_error() !== JSON_ERROR_NONE) {
error_log('Invalid JSON payload');
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['error' => 'Invalid JSON']);
exit;
}
// Respond immediately
http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['received' => true]);
// Flush output to send response
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
// Process webhook after response
processWebhook($event);
function processWebhook($event) {
error_log("Processing event: " . $event['key']);
switch ($event['key']) {
case 'charge.complete':
handleChargeComplete($event['data']);
break;
case 'refund.create':
handleRefundCreate($event['data']);
break;
// Add more handlers
}
}
function handleChargeComplete($charge) {
// Your charge complete logic
}
function handleRefundCreate($refund) {
// Your refund create logic
}
?>
PHP (Laravel)โ
<?php
// app/Http/Controllers/WebhookController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use App\Jobs\ProcessWebhook;
class WebhookController extends Controller
{
private $webhookKey;
public function __construct()
{
$this->webhookKey = config('services.omise.webhook_key');
}
public function omise(Request $request)
{
// Get raw body and signature
$payload = $request->getContent();
$signature = $request->header('X-Omise-Signature');
// Verify signature
if (!$signature || !$this->verifySignature($payload, $signature)) {
Log::error('Invalid webhook signature');
return response()->json(['error' => 'Invalid signature'], 401);
}
// Parse event
$event = json_decode($payload, true);
// Respond immediately
$response = response()->json(['received' => true], 200);
// Dispatch job to process webhook
ProcessWebhook::dispatch($event);
return $response;
}
private function verifySignature($payload, $signature)
{
$expectedSignature = hash_hmac('sha256', $payload, $this->webhookKey);
return hash_equals($signature, $expectedSignature);
}
}
// routes/api.php
Route::post('webhooks/omise', [WebhookController::class, 'omise']);
// app/Jobs/ProcessWebhook.php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Support\Facades\Log;
class ProcessWebhook implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $event;
public function __construct($event)
{
$this->event = $event;
}
public function handle()
{
Log::info('Processing webhook: ' . $this->event['key']);
switch ($this->event['key']) {
case 'charge.complete':
$this->handleChargeComplete($this->event['data']);
break;
case 'refund.create':
$this->handleRefundCreate($this->event['data']);
break;
}
}
private function handleChargeComplete($charge)
{
// Your charge complete logic
}
private function handleRefundCreate($refund)
{
// Your refund create logic
}
}
Goโ
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
)
var webhookKey = os.Getenv("OMISE_WEBHOOK_KEY")
type Event struct {
Object string `json:"object"`
ID string `json:"id"`
Key string `json:"key"`
CreatedAt string `json:"created_at"`
Data json.RawMessage `json:"data"`
}
func verifySignature(payload []byte, signature string) bool {
// Compute HMAC-SHA256
mac := hmac.New(sha256.New, []byte(webhookKey))
mac.Write(payload)
expectedSignature := hex.EncodeToString(mac.Sum(nil))
// Timing-safe comparison
return subtle.ConstantTimeCompare(
[]byte(signature),
[]byte(expectedSignature),
) == 1
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
// Read raw body
payload, err := ioutil.ReadAll(r.Body)
if err != nil {
log.Printf("Error reading body: %v", err)
http.Error(w, "Error reading request", http.StatusBadRequest)
return
}
defer r.Body.Close()
// Get signature from header
signature := r.Header.Get("X-Omise-Signature")
// Verify signature
if signature == "" || !verifySignature(payload, signature) {
log.Println("Invalid webhook signature")
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Parse event
var event Event
if err := json.Unmarshal(payload, &event); err != nil {
log.Printf("Error parsing JSON: %v", err)
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Respond immediately
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"received": true})
// Process webhook asynchronously
go processWebhook(event)
}
func processWebhook(event Event) {
log.Printf("Processing event: %s", event.Key)
switch event.Key {
case "charge.complete":
handleChargeComplete(event.Data)
case "refund.create":
handleRefundCreate(event.Data)
// Add more handlers
}
}
func handleChargeComplete(data json.RawMessage) {
// Your charge complete logic
}
func handleRefundCreate(data json.RawMessage) {
// Your refund create logic
}
func main() {
http.HandleFunc("/webhooks/omise", webhookHandler)
log.Println("Starting webhook server on :3000")
if err := http.ListenAndServe(":3000", nil); err != nil {
log.Fatal(err)
}
}
Timing-Safe Comparisonโ
Always use timing-safe comparison functions to prevent timing attacks. Regular string comparison (==, ===) can leak information about the signature through timing differences.
Why Timing-Safe Comparison Mattersโ
// VULNERABLE - Do not use
function unsafeCompare(a, b) {
return a === b; // Returns early on first mismatch
}
// SECURE - Use this
function timingSafeCompare(a, b) {
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
Timing attack explanation:
- Regular comparison returns immediately when characters don't match
- Attacker can measure response time to guess correct characters
- Timing-safe comparison always takes the same time regardless of where mismatch occurs
Built-in Timing-Safe Functionsโ
| Language | Function |
|---|---|
| Node.js | crypto.timingSafeEqual() |
| Python | hmac.compare_digest() |
| Ruby | Rack::Utils.secure_compare() or ActiveSupport::SecurityUtils.secure_compare() |
| PHP | hash_equals() |
| Go | subtle.ConstantTimeCompare() |
Webhook Key Managementโ
Storing Webhook Keys Securelyโ
Best practices:
- Store in environment variables (not in code)
- Use secrets management systems (AWS Secrets Manager, HashiCorp Vault, etc.)
- Never commit keys to version control
- Use different keys for test and live modes
- Restrict access to keys using IAM policies
# Environment variables
export OMISE_WEBHOOK_KEY="your_webhook_key_here"
# Docker
docker run -e OMISE_WEBHOOK_KEY="your_key" your-app
# Kubernetes secret
kubectl create secret generic omise-webhook \
--from-literal=key=your_webhook_key_here
AWS Secrets Manager Exampleโ
// Node.js - Loading from AWS Secrets Manager
const AWS = require('aws-sdk');
const secretsManager = new AWS.SecretsManager();
async function getWebhookKey() {
try {
const data = await secretsManager.getSecretValue({
SecretId: 'omise/webhook-key'
}).promise();
return JSON.parse(data.SecretString).webhook_key;
} catch (error) {
console.error('Error fetching secret:', error);
throw error;
}
}
// Cache the key
let cachedWebhookKey = null;
async function verifySignature(rawBody, signature) {
if (!cachedWebhookKey) {
cachedWebhookKey = await getWebhookKey();
}
const expectedSignature = crypto
.createHmac('sha256', cachedWebhookKey)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
Secret Rotationโ
Rotate webhook signing keys periodically to maintain security.
Rotation Procedureโ
- Generate new webhook endpoint in Omise dashboard
- Update your application to verify signatures with both old and new keys
- Monitor webhook deliveries to ensure both keys work
- Remove old key after transition period (e.g., 7 days)
- Deactivate old webhook endpoint in dashboard
Dual-Key Verificationโ
Support both old and new keys during rotation:
// Node.js - Dual-key verification during rotation
const OLD_WEBHOOK_KEY = process.env.OMISE_WEBHOOK_KEY_OLD;
const NEW_WEBHOOK_KEY = process.env.OMISE_WEBHOOK_KEY_NEW;
function verifySignatureWithRotation(rawBody, signature) {
// Try new key first
if (NEW_WEBHOOK_KEY) {
const newSignature = crypto
.createHmac('sha256', NEW_WEBHOOK_KEY)
.update(rawBody)
.digest('hex');
if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(newSignature))) {
console.log('Verified with new key');
return true;
}
}
// Fall back to old key
if (OLD_WEBHOOK_KEY) {
const oldSignature = crypto
.createHmac('sha256', OLD_WEBHOOK_KEY)
.update(rawBody)
.digest('hex');
if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(oldSignature))) {
console.log('Verified with old key (rotation in progress)');
return true;
}
}
return false;
}
# Python - Dual-key verification
import hmac
import hashlib
import os
OLD_WEBHOOK_KEY = os.getenv('OMISE_WEBHOOK_KEY_OLD')
NEW_WEBHOOK_KEY = os.getenv('OMISE_WEBHOOK_KEY_NEW')
def verify_signature_with_rotation(payload, signature):
# Try new key first
if NEW_WEBHOOK_KEY:
new_signature = hmac.new(
NEW_WEBHOOK_KEY.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
if hmac.compare_digest(signature, new_signature):
print('Verified with new key')
return True
# Fall back to old key
if OLD_WEBHOOK_KEY:
old_signature = hmac.new(
OLD_WEBHOOK_KEY.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
if hmac.compare_digest(signature, old_signature):
print('Verified with old key (rotation in progress)')
return True
return False
Replay Attack Preventionโ
Prevent attackers from replaying captured webhook events.
Implementation Strategiesโ
1. Event ID Trackingโ
Store processed event IDs to reject duplicates:
// Node.js - Using Redis for event ID tracking
const redis = require('redis');
const client = redis.createClient();
async function isEventProcessed(eventId) {
const key = `webhook:processed:${eventId}`;
const exists = await client.exists(key);
if (exists) {
return true; // Already processed
}
// Mark as processed (expire after 7 days)
await client.setex(key, 7 * 24 * 3600, '1');
return false;
}
app.post('/webhooks/omise', async (req, res) => {
const signature = req.headers['x-omise-signature'];
const rawBody = req.body;
// Verify signature
if (!verifySignature(rawBody, signature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(rawBody.toString());
// Check for replay
if (await isEventProcessed(event.id)) {
console.log(`Duplicate event detected: ${event.id}`);
return res.status(200).json({ received: true });
}
res.status(200).json({ received: true });
processWebhook(event);
});
# Python - Using Redis for event ID tracking
import redis
import time
redis_client = redis.Redis(host='localhost', port=6379, db=0)
def is_event_processed(event_id):
key = f'webhook:processed:{event_id}'
# Check if exists
if redis_client.exists(key):
return True # Already processed
# Mark as processed (expire after 7 days)
redis_client.setex(key, 7 * 24 * 3600, '1')
return False
@app.route('/webhooks/omise', methods=['POST'])
def handle_webhook():
payload = request.get_data()
signature = request.headers.get('X-Omise-Signature')
# Verify signature
if not verify_signature(payload, signature):
return jsonify({'error': 'Invalid signature'}), 401
event = request.get_json()
# Check for replay
if is_event_processed(event['id']):
print(f"Duplicate event detected: {event['id']}")
return jsonify({'received': True}), 200
process_webhook(event)
return jsonify({'received': True}), 200
2. Timestamp Verificationโ
Reject events that are too old:
// Node.js - Timestamp verification
function isEventTimestampValid(createdAt, maxAgeMinutes = 5) {
const eventTime = new Date(createdAt);
const now = new Date();
const ageMinutes = (now - eventTime) / (1000 * 60);
return ageMinutes <= maxAgeMinutes;
}
app.post('/webhooks/omise', (req, res) => {
// Verify signature first
if (!verifySignature(req.body, req.headers['x-omise-signature'])) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body.toString());
// Verify timestamp
if (!isEventTimestampValid(event.created_at)) {
console.log(`Event too old: ${event.id}`);
return res.status(400).json({ error: 'Event too old' });
}
res.status(200).json({ received: true });
processWebhook(event);
});
// Go - Timestamp verification
func isEventTimestampValid(createdAt string, maxAgeMinutes int) bool {
eventTime, err := time.Parse(time.RFC3339, createdAt)
if err != nil {
return false
}
ageMinutes := time.Since(eventTime).Minutes()
return ageMinutes <= float64(maxAgeMinutes)
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
// ... signature verification ...
var event Event
json.Unmarshal(payload, &event)
// Verify timestamp
if !isEventTimestampValid(event.CreatedAt, 5) {
log.Printf("Event too old: %s", event.ID)
http.Error(w, "Event too old", http.StatusBadRequest)
return
}
// Process event
}
3. Combined Approachโ
Use both event ID tracking and timestamp verification:
# Ruby - Combined replay protection
require 'redis'
require 'time'
REDIS = Redis.new
MAX_EVENT_AGE_MINUTES = 5
def is_event_processed?(event_id)
key = "webhook:processed:#{event_id}"
# Check if exists
return true if REDIS.exists?(key)
# Mark as processed (expire after 7 days)
REDIS.setex(key, 7 * 24 * 3600, '1')
false
end
def is_event_timestamp_valid?(created_at)
event_time = Time.parse(created_at)
age_minutes = (Time.now - event_time) / 60
age_minutes <= MAX_EVENT_AGE_MINUTES
end
post '/webhooks/omise' do
payload = request.body.read
signature = request.env['HTTP_X_OMISE_SIGNATURE']
# Verify signature
unless verify_signature(payload, signature)
halt 401, { error: 'Invalid signature' }.to_json
end
event = JSON.parse(payload)
# Check timestamp
unless is_event_timestamp_valid?(event['created_at'])
logger.warn "Event too old: #{event['id']}"
halt 400, { error: 'Event too old' }.to_json
end
# Check for duplicate
if is_event_processed?(event['id'])
logger.info "Duplicate event: #{event['id']}"
status 200
return { received: true }.to_json
end
# Process event
status 200
Thread.new { process_webhook(event) }
{ received: true }.to_json
end
Best Practicesโ
Security Checklistโ
- Verify HMAC-SHA256 signature for all webhooks
- Use timing-safe comparison functions
- Store webhook keys in environment variables or secrets manager
- Use HTTPS with valid SSL certificates
- Implement event ID tracking to prevent replay attacks
- Verify event timestamps to reject old events
- Log all verification failures for security monitoring
- Respond with 401 for invalid signatures
- Use raw request body for signature verification
- Rotate webhook keys periodically
- Monitor webhook endpoint for suspicious activity
- Rate limit webhook endpoint to prevent abuse
Common Pitfalls to Avoidโ
1. Parsing body before verification
// WRONG - Don't parse before verifying
app.use(express.json());
app.post('/webhooks', (req, res) => {
verifySignature(JSON.stringify(req.body), signature); // Won't match!
});
// CORRECT - Verify raw body
app.use('/webhooks', express.raw({ type: 'application/json' }));
app.post('/webhooks', (req, res) => {
verifySignature(req.body, signature); // Correct
const event = JSON.parse(req.body);
});
2. Using unsafe string comparison
# WRONG - Vulnerable to timing attacks
if signature == expected_signature:
process_webhook()
# CORRECT - Use timing-safe comparison
if hmac.compare_digest(signature, expected_signature):
process_webhook()
3. Hardcoding webhook keys
// WRONG - Never hardcode keys
const WEBHOOK_KEY = 'wkey_test_123abc...';
// CORRECT - Use environment variables
const WEBHOOK_KEY = process.env.OMISE_WEBHOOK_KEY;
4. Not handling missing signatures
// WRONG - Can cause errors
const signature = req.headers['x-omise-signature'];
verifySignature(body, signature); // Error if signature is undefined
// CORRECT - Check for missing signature
const signature = req.headers['x-omise-signature'];
if (!signature) {
return res.status(401).json({ error: 'Missing signature' });
}
verifySignature(body, signature);
5. Exposing error details
// WRONG - Exposes internal details
catch (error) {
res.status(500).json({ error: error.message, stack: error.stack });
}
// CORRECT - Generic error message
catch (error) {
logger.error('Webhook error:', error);
res.status(500).json({ error: 'Internal server error' });
}
Security Monitoringโ
Monitor webhook security events:
// Node.js - Security monitoring
const logger = require('winston').createLogger({
transports: [
new winston.transports.File({ filename: 'webhook-security.log' })
]
});
app.post('/webhooks/omise', (req, res) => {
const signature = req.headers['x-omise-signature'];
const ip = req.ip || req.connection.remoteAddress;
// Log all webhook attempts
logger.info('Webhook received', {
ip,
signature: signature ? 'present' : 'missing',
event_id: req.body.id
});
if (!signature) {
logger.warn('Missing signature', { ip });
return res.status(401).json({ error: 'Missing signature' });
}
if (!verifySignature(req.body, signature)) {
logger.error('Invalid signature', {
ip,
signature,
event_id: req.body.id
});
// Alert security team if multiple failures from same IP
checkForBruteForce(ip);
return res.status(401).json({ error: 'Invalid signature' });
}
logger.info('Signature verified', { event_id: req.body.id });
res.status(200).json({ received: true });
processWebhook(req.body);
});
// Track failed attempts
const failedAttempts = new Map();
function checkForBruteForce(ip) {
const count = (failedAttempts.get(ip) || 0) + 1;
failedAttempts.set(ip, count);
if (count >= 5) {
logger.error('Possible brute force attack', { ip, attempts: count });
// Send alert to security team
alertSecurityTeam({ type: 'brute_force', ip, attempts: count });
}
// Reset counter after 1 hour
setTimeout(() => failedAttempts.delete(ip), 3600000);
}
Troubleshootingโ
Signature Verification Failuresโ
Problem: Signature verification always fails
Solutions:
-
Check webhook key is correct
# Print key (be careful in production)
echo $OMISE_WEBHOOK_KEY -
Verify raw body is used
// Log raw body for debugging
console.log('Raw body:', req.body);
console.log('Signature:', req.headers['x-omise-signature']); -
Check for body parsing middleware
// Ensure raw body is preserved
app.use('/webhooks/omise', express.raw({ type: 'application/json' })); -
Test signature computation
const crypto = require('crypto');
const testPayload = '{"key":"test"}';
const testKey = 'your_webhook_key';
const signature = crypto
.createHmac('sha256', testKey)
.update(testPayload)
.digest('hex');
console.log('Test signature:', signature);
Missing Signature Headerโ
Problem: X-Omise-Signature header is not present
Solutions:
- Check header name (case-sensitive in some frameworks)
- Verify webhook is configured in Omise dashboard
- Check proxy/load balancer isn't stripping headers
- Test with curl:
curl -X POST https://your-domain.com/webhooks/omise \
-H "Content-Type: application/json" \
-H "X-Omise-Signature: test_signature" \
-d '{"key":"test"}'
Event Replay Issuesโ
Problem: Legitimate events marked as replays
Solutions:
- Check Redis/cache connection
- Verify event ID extraction
- Adjust timestamp tolerance
- Clear processed events cache if needed
# Redis - Clear processed events
redis-cli KEYS "webhook:processed:*" | xargs redis-cli DEL
FAQโ
How do I get my webhook signing key?โ
The webhook signing key is displayed in the Omise dashboard when you create a new webhook endpoint. It's shown only once, so store it securely. If lost, create a new webhook endpoint.
Can I use the same webhook key for test and live modes?โ
No, test and live modes have separate webhook endpoints and signing keys. Configure different endpoints for each mode.
What happens if signature verification fails?โ
Return HTTP 401 Unauthorized. Omise will retry the webhook delivery according to the retry schedule.
Should I verify signatures for test mode webhooks?โ
Yes, always verify signatures for both test and live modes to ensure your verification code works correctly before going live.
How often should I rotate webhook keys?โ
Rotate keys every 6-12 months or immediately if you suspect compromise. Implement dual-key verification to avoid downtime during rotation.
Can I whitelist Omise IP addresses instead of verifying signatures?โ
No, IP whitelisting alone is insufficient. Always verify signatures as IP addresses can be spoofed or may change.
What should I do if I detect a replay attack?โ
Log the event, respond with 200 OK to prevent retries, and monitor for patterns. Alert your security team if attacks persist.
How long should I store processed event IDs?โ
Store event IDs for at least 7 days (Omise retry period). Longer retention (30 days) provides better protection.
Can I verify signatures synchronously?โ
Yes, signature verification is fast (a few milliseconds). Verify synchronously before responding to the webhook.
What if my webhook key is compromised?โ
Immediately create a new webhook endpoint with a new key, update your application, and deactivate the compromised endpoint.
Related Resourcesโ
- Setup Guide - Configure webhook endpoints
- Event Types - Complete event type reference
- Retry Logic - Understanding retries and idempotency
- API Authentication - API security best practices
- Testing Guide - Testing webhook security
Next Stepsโ
After implementing webhook security:
- Test signature verification with test mode webhooks
- Implement replay protection using event ID tracking
- Set up security monitoring and alerting
- Plan key rotation procedure
- Review and test failure scenarios