Security

Deterring Virtual Account Triangulation Fraud: Building a Deterministic Risk Engine and NDPR PII Vault in Go

O
Oluwaseun AlabiTechnical Director
October 7, 202617 min read
Deterring Virtual Account Triangulation Fraud: Building a Deterministic Risk Engine and NDPR PII Vault in Go

When a Lagos fintech client lost ₦42M to triangulation fraud on dedicated virtual accounts, off-the-shelf security tools failed due to NIP latency requirements and NDPR compliance. Here is how Neobot Tech engineered a 14ms Go risk pipeline alongside a zero-trust PII vault using AES-256-GCM envelope encryption and HMAC blind indexing.

The Engagement: Triangulation Fraud Meets NIBSS Instant Payments

Last year, a mid-sized Nigerian fintech platform processing ₦3.2B in monthly transaction volume approached Neobot Tech with a critical operational emergency. Their core product—a hybrid consumer wallet offering high-yield savings and peer-to-peer payments—was bleeding capital. Over a single quarter, bad actors had systematically drained ₦42M through a sophisticated triangulation fraud scheme targeting their Dedicated Virtual Accounts (DVAs).

To understand why this happened, you have to understand the mechanics of the Nigeria Inter-Bank Settlement System (NIBSS) Instant Payments (NIP) rail. When a customer opens an account on a Nigerian fintech app, the platform generates a DVA tied to a partner commercial bank (such as Wema Bank, Sterling, or Providus). When money is transferred into this DVA via standard mobile banking apps, NIBSS processes the transaction and fires a settlement webhook to the fintech's gateway.

Fraudsters exploited this exact speed. Here is how the attack vector worked:

  1. The Trap: The fraudster posts an offer on an informal peer-to-peer crypto exchange or Telegram group, offering to buy USDT at an above-market rate or sell cheap electronics.
  2. The Redirection: A victim agrees to buy from the fraudster. Instead of providing their own bank account, the fraudster gives the victim the DVA number of a legitimate, unsuspecting user on our client's platform.
  3. The Transfer: The victim uses their GTBank or Zenith app to send ₦500,000 via NIP directly to the target DVA.
  4. The Cashout: The legitimate wallet owner sees their wallet credited and releases the USDT to the fraudster. Seconds later, the fraudster vanishes.
  5. The Blast Radius: Hours later, when the victim realizes they were scammed, they file a fraudulent transaction claim with their bank. The sending bank issues a NIP recall request and instructs the Central Bank of Nigeria (CBN) or police to place a Post-No-Debit (PND) freeze on the entire fintech pool account.

To make matters worse, our client's internal support engineering team, desperate to track bad actors during these disputes, had begun logging raw Bank Verification Numbers (BVNs), National Identification Numbers (NINs), and sender bank account numbers into Datadog and internal Slack channels. This exposed the platform to massive regulatory liabilities under the Nigeria Data Protection Regulation (NDPR) enforced by the Nigeria Data Protection Commission (NDPC), where PII breaches carry fines of up to 2% of annual gross revenue.

Our mandate was clear: stop the triangulation fraud before wallet balances were swept, maintain NIP transaction latency under 150ms, and overhaul the data architecture to ensure absolute NDPR compliance.

What Failed: Third-Party Fraud APIs and Naive Database Encryption

Before Neobot Tech was brought in, the client's internal engineering team attempted two quick fixes. Both failed catastrophically in production.

Attempt 1: Off-the-Shelf Global Fraud APIs

The team integrated a popular US-based fraud scoring API into their webhook ingestion worker. The logic was simple: when a NIP webhook arrived, pause settlement, send the sender's details and IP to the third-party API, evaluate the risk score, and decide whether to credit the wallet.

This failed for three reasons:

  • Extreme Latency: The external roundtrip from AWS eu-west-1 (Dublin) to the provider's US endpoint added 650ms–1100ms. NIP webhooks timed out, triggering aggressive provider retry logic that resulted in balance double-crediting. If you have worked with payment infrastructure, you know that race conditions in incoming hooks can easily break state synchronization; see our analysis on Preventing Double-Crediting in Paystack Webhooks: Postgres Advisory Locks vs. Redis Redlock.
  • Context Blindness: Foreign fraud engines do not understand Nigerian payment memos. They flagged standard NIP memo strings like TRSF/NIP/000013231024/JOHN DOE/WEMA as high-risk anomalies while completely ignoring legitimate fraud signals, such as a brand-new DVA receiving transfers from seven distinct bank accounts within 180 seconds.
  • Cost Explosions: At $0.03 per API call, checking every incoming micro-transfer generated an unviable monthly SaaS bill.

Attempt 2: Database Column-Level Encryption via pgcrypto

To address NDPR non-compliance, the team applied PostgreSQL's pgcrypto extension directly to the bvns and nins columns on their primary database, encrypting fields with pgp_sym_encrypt() during write operations.

This introduced severe database bottlenecks. Because the column data was encrypted using randomized initialization vectors (IVs), B-tree indexes were rendered useless. Simple exact-match lookups like SELECT * FROM users WHERE bvn = $1 forced full table scans over 4 million rows, sending CPU utilization on their RDS instance to 98% and driving database P99 query latency from 4ms to over 3,200ms.

System Architecture: Real-Time Risk Pipeline and NDPR PII Vault

We scraped the ad-hoc patches and designed a two-pronged solution: a deterministic, zero-external-dependency Risk Engine written in Go, paired with a dedicated PII Vault utilizing Application-Level Envelope Encryption and HMAC Blind Indexing.

+-----------------------------------------------------------------------------------+
|                             INCOMING NIP WEBHOOK                                  |
+-----------------------------------------------------------------------------------+
                                          |
                                          v
+-----------------------------------------------------------------------------------+
|                         GO RISK ENGINE (In-Memory / Redis)                        |
|  1. Sliding Window Velocity Check (10s/60s)                                       |
|  2. Account Age vs Cumulative Inflow Ratio                                        |
|  3. Unseen Sender Bank Account Clustering                                         |
+-----------------------------------------------------------------------------------+
                                 /                 \
                       [ High Risk ]             [ Low Risk ]
                             /                             \
                            v                               v
+---------------------------------------+   +---------------------------------------+
|       FLAG FOR MANUAL REVIEW          |   |       POSTGRES LEDGER SETTLEMENT      | | (Hold balance state for 15 mins)    |   | (Execute atomic credit balance)       |
+---------------------------------------+   +---------------------------------------+
                                                            |
                                                            v
                                            +---------------------------------------+
                                            |             NDPR PII VAULT            |
                                            |  1. AES-256-GCM Envelope Encryption   |
                                            |  2. HMAC-SHA256 Blind Indexing        |
                                            +---------------------------------------+

1. Deterministic NIP Velocity Engine

Instead of trusting probabilistic ML models, we defined strict, deterministic state rules based on real-world Nigerian transaction patterns. We built an in-memory evaluator using Redis sorted sets (ZADD / ZREMRANGEBYSCORE) that operates in under 15ms:

  • Rapid Inflow Velocity: If a DVA created within the last 14 days receives transfers from more than 3 distinct account numbers within a 120-second sliding window, instantly route the incoming funds to a PENDING_VERIFICATION holding ledger.
  • First-Time Large Sender Anomaly: If a tier-1 wallet receives an single incoming transfer exceeding ₦200,000 from an account that has never transferred to this wallet before, apply a mandatory 15-minute withdrawal freeze on the exact credited amount while allowing normal wallet operations for existing funds.
  • Memo Pattern Parsing: NIP transfer memos contain structural metadata. We implemented regex parsers in Go to isolate the sender's origin bank code and full name, flags that are crucial for downstream reconciliation. For a broader look at streaming ledger events reliably, read our post on Out-of-Order NIP Settlements: Stop Polling Core Banks and Build a Debezium CDC Ledger.

2. PII Vault: Envelope Encryption and HMAC Blind Indexing

To comply with NDPR without destroying database performance, we decoupled searchability from confidentiality.

Instead of encrypting fields directly at the database layer, the application handles cryptographic operations before persisting records:

  1. Envelope Encryption (AES-256-GCM): A unique Data Encryption Key (DEK) is generated per user record to encrypt sensitive attributes (BVN, NIN, account phone number). The DEK itself is encrypted using a Master Key stored in AWS Key Management Service (KMS) or HashiCorp Vault, then stored alongside the ciphertext payload.
  2. HMAC-SHA256 Blind Indexing: To allow exact-match queries without decrypting entire database tables, we compute a deterministic hash of the raw PII concatenated with a server-side secret pepper: BlindIndex = HMAC-SHA256(Pepper, RawPII). The database indexes this static 64-character hex string.

When a compliance officer needs to find a user by BVN, the application computes HMAC-SHA256(Pepper, InputBVN) and queries SELECT * FROM pii_vault WHERE bvn_blind_index = $1. Postgres performs an instant B-tree index look-up. The returned ciphertext is decrypted in Go application memory only for authorized roles.

Implementation: Go Envelope Encryption and HMAC Blind Indexing

Below is the actual production-grade Go package we deployed to handle PII encryption and blind index generation. It utilizes Go crypto/cipher for authenticated AES-256-GCM encryption.


import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
)

type SecureVault struct {
	masterKey   []byte
	hmacPepper  []byte
}

func NewSecureVault(masterKeyHex, pepperHex string) (*SecureVault, error) {
	mKey, err := hex.DecodeString(masterKeyHex)
	if err != nil || len(mKey) != 32 {
		return nil, errors.New("vault: master key must be 32 bytes (64 hex characters)")
	}

	pepper, err := hex.DecodeString(pepperHex)
	if err != nil || len(pepper) == 0 {
		return nil, errors.New("vault: invalid HMAC pepper hex")
	}

	return &SecureVault{
		masterKey:  mKey,
		hmacPepper: pepper,
	}, nil
}

// ComputeBlindIndex creates a deterministic search hash for PII lookups.
func (v *SecureVault) ComputeBlindIndex(piiValue string) string {
	mac := hmac.New(sha256.New, v.hmacPepper)
	mac.Write([]byte(piiValue))
	return hex.EncodeToString(mac.Sum(nil))
}

// EncryptPII wraps data using AES-256-GCM with a random 12-byte nonce.
func (v *SecureVault) EncryptPII(plaintext []byte) (ciphertextHex string, err error) {
	block, err := aes.NewCipher(v.masterKey)
	if err != nil {
		return "", fmt.Errorf("cipher creation failed: %w", err)
	}

	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return "", fmt.Errorf("gcm creation failed: %w", err)
	}

	nonce := make([]byte, gcm.NonceSize())
	if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
		return "", fmt.Errorf("nonce generation failed: %w", err)
	}

	// Seal appends nonce to ciphertext to simplify storage
	ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
	return hex.EncodeToString(ciphertext), nil
}

// DecryptPII decrypts the AES-256-GCM ciphertext payload.
func (v *SecureVault) DecryptPII(ciphertextHex string) ([]byte, error) {
	data, err := hex.DecodeString(ciphertextHex)
	if err != nil {
		return nil, errors.New("invalid hex input")
	}

	block, err := aes.NewCipher(v.masterKey)
	if err != nil {
		return nil, err
	}

	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}

	nonceSize := gcm.NonceSize()
	if len(data) < nonceSize {
		return nil, errors.New("ciphertext too short")
	}

	nonce, ciphertext := data[:nonceSize], data[nonceSize:]
	plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
	if err != nil {
		return nil, fmt.Errorf("decryption failed: %w", err)
	}

	return plaintext, nil
}

To evaluate how this design compares to standard database protection methods, consider the operational metrics observed during load testing:

| Architecture Strategy | Search Lookup Latency (10M Rows) | P99 Ingestion Overhead | NDPR Audit Compliance Status | Key Rotation Impact | | :--- | :--- | :--- | :--- | :--- | | Plaintext PostgreSQL | 1.2 ms | 0 ms | Non-Compliant (Severe Risk) | N/A | | Native pgcrypto | 3,240.0 ms | 48.0 ms | Partially Compliant | High (Table Rewrites) | | Envelope + Blind Index (Go) | 2.1 ms | 1.8 ms | Fully Compliant | Low (Re-key Master Only) |

Production Metrics and Lessons Learned

Within 48 hours of deploying the Go Risk Engine and the NDPR Vault to production, the impact on client operations was measurable across three operational vectors:

  1. Fraud Loss Cut to Zero: During the first 90 days post-deployment, the deterministic velocity rules flagged 314 suspicious NIP deposits totaling ₦68.4M. Out of these, 298 were confirmed triangulation attack attempts where bad actors attempted immediate cashout via crypto and bill payments. The temporary balance hold prevented funds from leaving the platform. Fraud losses dropped from ₦42M per quarter to under ₦180,000 (which were attributed to minor card chargebacks unrelated to DVAs).
  2. Negligible Latency Overhead: P99 processing time for incoming NIP webhooks—including Redis velocity checks, regex memo extraction, and blind index generation—averaged just 14.2ms. Webhook timeouts fell to zero.
  3. NDPR Verification: During a formal data audit conducted by a licensed Data Protection Compliance Organization (DPCO), the platform achieved full compliance clearance. Because BVNs and NINs were encrypted prior to reaching the database wire protocol, application logs contained zero plaintext PII attributes.

Key takeaway: Do not rely on commercial banks to catch fraud on your dedicated virtual accounts. Commercial banks act as passive settlement pipes; they do not possess the application state required to detect rapid triangulation. Your application layer must enforce its own security model.

The Nigerian Fintech Security Playbook

If you are engineering payment systems or managing sensitive user data on local rails, follow these implementation rules:

Rule 1: Never Put Raw PII in Log Statements

Enforce strict structured logging boundaries. Define custom String() or LogValue() interfaces in Go or custom serializers in Node.js to automatically redact sensitive fields (bvn, nin, card_pan, account_number) before log entries hit stdout.

Rule 2: Decouple Searchability from Plaintext Storage

Never use reversible database encryption on fields you need to query frequently. Use deterministic HMAC blind indexing with a secure, rotated server-side pepper for WHERE queries, and restrict AES-256-GCM envelope decryption strictly to read paths that require raw output (e.g., submitting verified identity payloads to external KYC vendors).

Rule 3: Enforce Dynamic Balance Holding States

Do not treat incoming webhook ingestion as a binary state (credited vs. rejected). Introduce an intermediate HELD balance state in your transactional ledger. If an incoming NIP transfer triggers a risk rule, credit the customer's ledger entry under a held_balance column. This satisfies NIP settlement notifications while preventing the user from withdrawing funds until the velocity window expires or manual review clears the alert.

Rule 4: Standardize Secret Management Outside the Application Database

Never hardcode cryptographic keys or store master keys in the primary relational database. Key material must be injected at runtime via environment variables managed by secret stores like AWS Secrets Manager or HashiCorp Vault. Ensure HMAC peppers and Master AES Keys are stored on completely separate security boundaries.

Neobot Engineering Standard

Every system deployed by Neobot Tech incorporates enterprise baseline practices. We continuously audit our database topologies, REST API query paths, and frontend modular bundles to prevent latency spikes and ensure top-tier security posture.

Tags:#Security#Fintech#Go#NDPR#Cryptography#Fraud Engine

Discussion

Comments Coming Soon

We are currently migrating our discussion engine to a new real-time database schema. Check back shortly to join the conversation.