Skip to main content

Command Palette

Search for a command to run...

Webhook Signature Verification: Panduan Keamanan untuk API Mutasi Bank

Updated
12 min read
Webhook Signature Verification: Panduan Keamanan untuk API Mutasi Bank
M

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

  1. Kenapa Webhook Signature Penting?

  2. Cara Kerja HMAC-SHA256

  3. Implementation Guide

  4. Testing & Debugging

  5. Common Mistakes

  6. FAQ


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:

  1. Kirim fake POST request ke endpoint Anda

  2. Dengan payload yang dibuat-buat (fake transaction)

  3. Trigger auto-confirm untuk order yang belum dibayar

  4. 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:

HeaderFormatPurpose
X-Mutasibank-Signature64 hex charsHMAC-SHA256 signature
X-Mutasibank-TimestampUnix timestampPrevent replay attacks
X-Mutasibank-Webhook-IdUUIDWebhook identifier
Content-Typeapplication/jsonPayload format

Implementation Guide

<?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";
}

📥 Download complete example


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');
});

📥 Download complete example


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)

📥 Download complete example


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?

  1. ✅ URL webhook publicly accessible (bukan localhost)?

  2. ✅ SSL certificate valid?

  3. ✅ Port 80/443 open (firewall)?

  4. ✅ 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 match

  • Attacker 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

  • $_POST is 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:

  1. Login → Webhook Settings

  2. Create webhook atau view existing

  3. Secret akan ditampilkan (64 characters)

  4. 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:

  1. Old secret langsung invalid

  2. Webhook existing akan gagal verification

  3. Harus update secret di server Anda

  4. 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

👉 Daftar Gratis 7 Hari


📚 Resources

Code Examples:

Support:

Related Articles:


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