Webhook Signature Verification: Panduan Keamanan untuk API Mutasi Bank

Banking automation & API integration tutorials for Indonesian developers
Protect your webhook endpoints dari spoofing attacks dengan HMAC-SHA256 signature verification
📌 TL;DR (Quick Summary)
Webhook tanpa signature verification = SANGAT BERBAHAYA! Siapa saja bisa kirim fake webhook dan manipulasi sistem Anda.
Learn how to:
✅ Verify HMAC-SHA256 signatures dengan benar
✅ Prevent replay attacks menggunakan timestamp validation
✅ Implement di 3 bahasa: PHP, Node.js, Python
✅ Production-ready code dengan error handling
✅ Test locally sebelum deploy
Reading time: 10 menit • Level: Intermediate • Security: Critical 🔒
Daftar Isi
Kenapa Webhook Signature Penting?
🚨 Scenario Tanpa Signature Verification:
Bayangkan Anda punya endpoint untuk auto-confirm order:
// ❌ DANGEROUS CODE - DON'T DO THIS!
<?php
// webhook.php
$data = json_decode(file_get_contents('php://input'), true);
foreach ($data['data_mutasi'] as $tx) {
if ($tx['amount'] == 100000) {
// Auto-confirm order
confirmOrder($orderId); // 😱 SIAPA SAJA BISA TRIGGER INI!
}
}
Attacker bisa:
Kirim fake POST request ke endpoint Anda
Dengan payload yang dibuat-buat (fake transaction)
Trigger auto-confirm untuk order yang belum dibayar
FRAUD! 💸
✅ Dengan Signature Verification:
// ✅ SECURE CODE
<?php
// 1. Verify signature FIRST
if (!verifySignature($payload, $signature, $secret)) {
die('Invalid signature'); // Reject fake webhooks
}
// 2. THEN process (only if signature valid)
$data = json_decode($payload, true);
confirmOrder($orderId); // ✅ Aman!
Benefit:
🔒 Only webhooks dari Mutasibank yang diterima
🛡️ Protect dari spoofing/fraud
✅ Compliance dengan security best practices
Cara Kerja HMAC-SHA256
What is HMAC?
HMAC = Hash-based Message Authentication Code
Ini adalah cara untuk "menandatangani" message dengan secret key:
Signature = HMAC-SHA256(Message, Secret Key)
Flow Diagram:
Mutasibank Server:
1. Ada transaksi baru
2. Buat payload JSON
3. Calculate: signature = hmac_sha256(payload, webhook_secret)
4. Send POST dengan headers:
- X-Mutasibank-Signature: [signature]
- X-Mutasibank-Timestamp: [unix_time]
- Body: [payload]
Your Server:
5. Receive POST request
6. Extract signature dari header
7. Get raw payload dari request body
8. Calculate: expected = hmac_sha256(payload, webhook_secret)
9. Compare: expected == signature?
- ✅ YES: Process webhook
- ❌ NO: Reject (fake webhook!)
Headers yang Dikirim:
| Header | Format | Purpose |
X-Mutasibank-Signature | 64 hex chars | HMAC-SHA256 signature |
X-Mutasibank-Timestamp | Unix timestamp | Prevent replay attacks |
X-Mutasibank-Webhook-Id | UUID | Webhook identifier |
Content-Type | application/json | Payload format |
Implementation Guide
PHP Implementation (RECOMMENDED)
<?php
/**
* Secure Webhook Handler with Signature Verification
*/
// ============================================================================
// CONFIGURATION
// ============================================================================
// Get from Mutasibank Dashboard → Webhook Settings
define('WEBHOOK_SECRET', getenv('WEBHOOK_SECRET'));
define('TIMESTAMP_TOLERANCE', 300); // 5 minutes
// ============================================================================
// STEP 1: EXTRACT HEADERS
// ============================================================================
$signature = $_SERVER['HTTP_X_MUTASIBANK_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_MUTASIBANK_TIMESTAMP'] ?? 0;
$webhookId = $_SERVER['HTTP_X_MUTASIBANK_WEBHOOK_ID'] ?? '';
// ============================================================================
// STEP 2: VALIDATE HEADERS
// ============================================================================
if (empty($signature) || empty($timestamp)) {
http_response_code(401);
die(json_encode([
'error' => 'Missing required headers',
'message' => 'X-Mutasibank-Signature and X-Mutasibank-Timestamp are required'
]));
}
// ============================================================================
// STEP 3: CHECK TIMESTAMP (Prevent Replay Attacks)
// ============================================================================
$currentTime = time();
$timeDiff = abs($currentTime - $timestamp);
if ($timeDiff > TIMESTAMP_TOLERANCE) {
http_response_code(401);
die(json_encode([
'error' => 'Request expired',
'message' => "Timestamp outside tolerance window ({$timeDiff}s difference)"
]));
}
// ============================================================================
// STEP 4: GET RAW PAYLOAD
// ============================================================================
// ⚠️ IMPORTANT: Use raw input, NOT $_POST!
// Signature is calculated on exact JSON bytes
$payload = file_get_contents('php://input');
if (empty($payload)) {
http_response_code(400);
die(json_encode(['error' => 'Empty payload']));
}
// ============================================================================
// STEP 5: VERIFY SIGNATURE
// ============================================================================
// Calculate expected signature
$expectedSignature = hash_hmac('sha256', $payload, WEBHOOK_SECRET);
// ⚠️ Use hash_equals() for constant-time comparison
// DON'T use == or === (vulnerable to timing attacks!)
if (!hash_equals($expectedSignature, $signature)) {
error_log("Invalid webhook signature from webhook_id: {$webhookId}");
http_response_code(401);
die(json_encode([
'error' => 'Invalid signature',
'message' => 'Signature verification failed'
]));
}
// ============================================================================
// ✅ SIGNATURE VALID - PROCESS WEBHOOK
// ============================================================================
$data = json_decode($payload, true);
// Log successful webhook
error_log("Webhook verified successfully: {$webhookId}");
// Process transactions
foreach ($data['data_mutasi'] as $transaction) {
$type = $transaction['type'] == 'CR' ? 'MASUK' : 'KELUAR';
echo "Processing [{$type}] Rp " . number_format($transaction['amount']) . "\n";
if ($transaction['type'] == 'CR') {
// Handle incoming transaction
processIncomingPayment($transaction);
} else {
// Handle outgoing transaction
processOutgoingPayment($transaction);
}
}
// ============================================================================
// SEND SUCCESS RESPONSE
// ============================================================================
http_response_code(200);
echo json_encode([
'success' => true,
'message' => 'Webhook processed',
'webhook_id' => $webhookId,
'transactions_processed' => count($data['data_mutasi'])
]);
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
function processIncomingPayment($transaction) {
// Your business logic here
// Example: Auto-confirm order, send email, update database
// Extract order ID from description
// Example: "TRANSFER FROM CUSTOMER ORDER-12345"
if (preg_match('/ORDER-(\d+)/', $transaction['description'], $matches)) {
$orderId = $matches[1];
// Confirm order in your database
// DB::table('orders')->where('id', $orderId)->update(['status' => 'paid']);
// Send notification
// sendWhatsApp($customer, "✅ Pembayaran order #{$orderId} confirmed!");
echo "✅ Order #{$orderId} confirmed\n";
}
}
function processOutgoingPayment($transaction) {
// Handle debit transactions if needed
echo "Debit transaction logged\n";
}
Node.js Implementation (Express)
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
app.use(express.raw({ type: 'application/json' }));
// Configuration
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
const TIMESTAMP_TOLERANCE = 300; // 5 minutes
// Webhook endpoint
app.post('/webhook/mutasibank', (req, res) => {
// Extract headers
const signature = req.headers['x-mutasibank-signature'] || '';
const timestamp = parseInt(req.headers['x-mutasibank-timestamp']) || 0;
const webhookId = req.headers['x-mutasibank-webhook-id'] || '';
// Validate headers
if (!signature || !timestamp) {
return res.status(401).json({
error: 'Missing signature headers'
});
}
// Check timestamp (prevent replay attacks)
const currentTime = Math.floor(Date.now() / 1000);
const timeDiff = Math.abs(currentTime - timestamp);
if (timeDiff > TIMESTAMP_TOLERANCE) {
return res.status(401).json({
error: 'Request expired',
message: `Timestamp outside tolerance (${timeDiff}s difference)`
});
}
// Get raw payload
const payload = JSON.stringify(req.body);
// Calculate expected signature
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(payload)
.digest('hex');
// Verify signature (constant-time comparison)
if (!crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(signature)
)) {
console.error('Invalid signature:', webhookId);
return res.status(401).json({
error: 'Invalid signature'
});
}
// ✅ Signature valid - process webhook
const data = req.body;
console.log('✅ Webhook verified:', webhookId);
// Process transactions
data.data_mutasi.forEach(transaction => {
if (transaction.type === 'CR') {
// Handle incoming payment
processIncomingPayment(transaction);
}
});
res.json({
success: true,
webhook_id: webhookId,
transactions_processed: data.data_mutasi.length
});
});
function processIncomingPayment(transaction) {
// Extract order ID
const match = transaction.description.match(/ORDER-(\d+)/);
if (match) {
const orderId = match[1];
console.log(`✅ Order #${orderId} confirmed`);
// Your business logic:
// - Update database
// - Send email
// - Send WhatsApp notification
}
}
app.listen(3000, () => {
console.log('Webhook server running on port 3000');
});
Python Implementation (Flask)
from flask import Flask, request, jsonify
import hmac
import hashlib
import time
import json
import os
app = Flask(__name__)
# Configuration
WEBHOOK_SECRET = os.getenv('WEBHOOK_SECRET').encode()
TIMESTAMP_TOLERANCE = 300 # 5 minutes
@app.route('/webhook/mutasibank', methods=['POST'])
def webhook_handler():
# Extract headers
signature = request.headers.get('X-Mutasibank-Signature', '')
timestamp = int(request.headers.get('X-Mutasibank-Timestamp', 0))
webhook_id = request.headers.get('X-Mutasibank-Webhook-Id', '')
# Validate headers
if not signature or not timestamp:
return jsonify({'error': 'Missing signature headers'}), 401
# Check timestamp (prevent replay attacks)
current_time = int(time.time())
time_diff = abs(current_time - timestamp)
if time_diff > TIMESTAMP_TOLERANCE:
return jsonify({
'error': 'Request expired',
'time_diff': time_diff
}), 401
# Get raw payload
payload = request.get_data()
# Calculate expected signature
expected_signature = hmac.new(
WEBHOOK_SECRET,
payload,
hashlib.sha256
).hexdigest()
# Verify signature (constant-time comparison)
if not hmac.compare_digest(expected_signature, signature):
print(f'Invalid signature: {webhook_id}')
return jsonify({'error': 'Invalid signature'}), 401
# ✅ Signature valid - process webhook
data = request.json
print(f'✅ Webhook verified: {webhook_id}')
# Process transactions
for transaction in data['data_mutasi']:
if transaction['type'] == 'CR':
process_incoming_payment(transaction)
return jsonify({
'success': True,
'webhook_id': webhook_id,
'transactions_processed': len(data['data_mutasi'])
})
def process_incoming_payment(transaction):
# Extract order ID from description
import re
match = re.search(r'ORDER-(\d+)', transaction['description'])
if match:
order_id = match.group(1)
print(f'✅ Order #{order_id} confirmed')
# Your business logic:
# - Update database
# - Send notifications
if __name__ == '__main__':
app.run(port=3000)
Security Best Practices
✅ DO (Must Implement!)
1. Always Verify Signature
// ✅ Good
if (!hash_equals($expected, $signature)) {
die('Invalid signature');
}
// ❌ Bad - DON'T USE ==
if ($expected == $signature) { // Vulnerable to timing attacks!
// ...
}
2. Check Timestamp (Prevent Replay Attacks)
$tolerance = 300; // 5 minutes
if (abs(time() - $timestamp) > $tolerance) {
die('Request expired');
}
3. Use Raw Payload
// ✅ Good
$payload = file_get_contents('php://input');
// ❌ Bad
$payload = json_encode($_POST); // Wrong! Signature won't match
4. Constant-Time Comparison
// ✅ Good (PHP)
hash_equals($expected, $signature)
// ✅ Good (Node.js)
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
// ✅ Good (Python)
hmac.compare_digest(expected, signature)
// ❌ Bad - ALL languages
$expected == $signature // Timing attack vulnerable!
5. HTTPS Only
✅ https://yoursite.com/webhook
❌ http://yoursite.com/webhook (INSECURE!)
6. Store Secret Securely
# .env file
WEBHOOK_SECRET=your_64_char_secret_here
# ❌ DON'T hardcode in code
define('SECRET', 'abc123...'); // BAD!
❌ DON'T (Common Mistakes!)
1. Skip Signature Verification
// ❌ NEVER DO THIS!
$data = json_decode(file_get_contents('php://input'), true);
processWebhook($data); // 😱 No verification = DANGEROUS!
2. Use Wrong Comparison
// ❌ Vulnerable to timing attacks
if ($expected == $signature) { ... }
// ✅ Use constant-time
if (hash_equals($expected, $signature)) { ... }
3. Log Sensitive Data
// ❌ DON'T log secret!
error_log("Secret: " . WEBHOOK_SECRET);
// ❌ DON'T log signature
error_log("Signature: " . $signature);
// ✅ Log only webhook ID
error_log("Webhook received: " . $webhookId);
4. Wrong Payload
// ❌ Wrong - signature calculated on raw bytes
$payload = json_encode($_POST);
// ✅ Correct - use raw input
$payload = file_get_contents('php://input');
Testing & Debugging
Local Testing dengan cURL
Generate signature secara manual untuk testing:
#!/bin/bash
# Configuration
SECRET="your_webhook_secret_64_chars"
TIMESTAMP=$(date +%s)
PAYLOAD='{"api_key":"test","account_id":1,"data_mutasi":[{"amount":100000}]}'
# Calculate signature
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)
# Send test webhook
curl -X POST http://localhost:8000/webhook.php \
-H "Content-Type: application/json" \
-H "X-Mutasibank-Signature: $SIGNATURE" \
-H "X-Mutasibank-Timestamp: $TIMESTAMP" \
-H "X-Mutasibank-Webhook-Id: test-webhook-123" \
-d "$PAYLOAD"
Expected response:
{
"success": true,
"webhook_id": "test-webhook-123",
"transactions_processed": 1
}
Debug Checklist
Webhook tidak terkirim?
✅ URL webhook publicly accessible (bukan localhost)?
✅ SSL certificate valid?
✅ Port 80/443 open (firewall)?
✅ Endpoint return 200 OK?
"Invalid signature" error?
Common causes:
// 1. ❌ Pakai $_POST instead of raw input
$payload = json_encode($_POST); // WRONG!
$payload = file_get_contents('php://input'); // CORRECT!
// 2. ❌ Secret salah
// Check di Mutasibank dashboard, copy exactly!
// 3. ❌ Pakai == instead of hash_equals()
if ($a == $b) // WRONG!
if (hash_equals($a, $b)) // CORRECT!
"Request expired" error?
// Server time tidak sync?
// Install NTP dan sync time
sudo ntpdate -s time.nist.gov
// Atau increase tolerance (production use 300 = 5 min)
define('TIMESTAMP_TOLERANCE', 600); // 10 minutes (for testing)
Common Mistakes & Solutions
Mistake #1: Using == for Comparison
Problem:
if ($expected == $signature) { // ❌ Timing attack vulnerable!
Why bad?
==operator return different time tergantung berapa karakter yang matchAttacker bisa brute-force signature character by character
Measuring response time → gradually discover signature
Solution:
if (hash_equals($expected, $signature)) { // ✅ Constant-time
Mistake #2: Wrong Payload
Problem:
$payload = json_encode($_POST); // ❌ Signature won't match!
Why bad?
Signature calculated on exact raw bytes dari request body
$_POSTis parsed & might change formatting
Solution:
$payload = file_get_contents('php://input'); // ✅ Raw input
Mistake #3: Not Checking Timestamp
Problem:
// ❌ No timestamp check
verifySignature($payload, $signature);
processWebhook($payload);
Why bad?
Attacker bisa capture valid webhook
Replay it multiple times
Trigger duplicate processing
Solution:
// ✅ Check timestamp first
if (abs(time() - $timestamp) > 300) {
die('Expired');
}
Mistake #4: Logging Sensitive Data
Problem:
// ❌ DON'T DO THIS!
error_log("Webhook secret: " . WEBHOOK_SECRET);
error_log("Full payload: " . $payload); // Might contain sensitive data
Solution:
// ✅ Log only non-sensitive info
error_log("Webhook ID: {$webhookId}");
error_log("Transactions count: " . count($data['data_mutasi']));
Production Deployment
Environment Variables
# .env
WEBHOOK_SECRET=your_64_character_secret_from_dashboard
API_TOKEN=your_api_token
TIMESTAMP_TOLERANCE=300
ENABLE_LOGGING=true
Server Requirements
✅ PHP 7.4+ / Node.js 14+ / Python 3.8+
✅ SSL certificate (HTTPS required)
✅ Port 443 open
✅ Firewall configured
✅ NTP time sync enabled
Monitoring
Log these events:
✅ Webhook received (dengan webhook_id)
✅ Signature verification success/failure
✅ Transactions processed count
❌ DON'T log: secrets, signatures, sensitive data
Alert on:
⚠️ Multiple signature failures (potential attack)
⚠️ Webhook endpoint down
⚠️ Processing errors
FAQ
Q: Apakah wajib implement signature verification?
A: YA, WAJIB! Tanpa ini, sistem Anda vulnerable terhadap fraud. Attacker bisa kirim fake webhooks dan manipulasi sistem Anda.
Q: Berapa tolerance timestamp yang aman?
A: 300 seconds (5 menit) adalah balance yang baik antara security dan tolerance untuk network delay. Jangan set terlalu besar (risiko replay attack).
Q: Dimana dapat webhook secret?
A: Di Mutasibank Dashboard:
Login → Webhook Settings
Create webhook atau view existing
Secret akan ditampilkan (64 characters)
Copy & simpan di .env - Tidak bisa lihat lagi setelah close!
Q: Webhook secret sama untuk semua webhook?
A: Tidak. Setiap webhook punya secret sendiri (auto-generated saat create). Jika punya multiple webhooks, simpan secret masing-masing.
Q: Bisa regenerate secret?
A: Ya, tapi:
Old secret langsung invalid
Webhook existing akan gagal verification
Harus update secret di server Anda
Coordination needed untuk zero-downtime
Q: Apa itu timing attack?
A: Timing attack adalah teknik hacking yang measure response time untuk discover secret:
// Vulnerable code:
if ($a == $b) {
// Comparison stops at first different character
// Different timing for different positions!
}
// Safe code:
if (hash_equals($a, $b)) {
// Always takes same time regardless of input
// ✅ Secure!
}
Kesimpulan
Webhook signature verification adalah critical security measure untuk protect sistem Anda dari fraud dan spoofing attacks.
Key Takeaways:
✅ Always verify HMAC-SHA256 signatures
✅ Check timestamp untuk prevent replay attacks
✅ Use constant-time comparison (hash_equals, timingSafeEqual)
✅ HTTPS only untuk webhook URLs ✅ Store secrets securely di environment variables
✅ Test thoroughly sebelum production
Implementation time: 30-60 menit untuk basic setup
Security impact: Protect dari potential fraud senilai jutaan rupiah! 🔒
🚀 Start Building Secure Webhooks
Sudah punya account Mutasibank?
✓ Login → Create webhook → Get secret
✓ Download sample code (PHP/Node.js/Python)
✓ Implement verification
✓ Test locally
✓ Deploy to production
📚 Resources
Code Examples:
Support:
Related Articles:
Coming soon: Best Practices Security untuk Banking API
Coming soon: Error Handling & Retry Logic
Questions tentang webhook security? Drop a comment below! 👇
Found this helpful? Share dengan developer friends! 🙏
Tags: #webhook #security #hmac #php #nodejs #python #api #banking #tutorial


