Cracking the Code: A Complete Guide to Reverse Engineering Nigeria's NUBAN System

nigerian uniform bank account number
Nigerian Uniform Bank Account Number

Table of Contents

  1. The Problem That Started It All
  2. Understanding NUBAN: The Foundation
  3. The Algorithm Deep Dive
  4. Building the Solution
  5. The Reverse Engineering Process
  6. Testing and Validation
  7. Building Your Own NUBAN Tool
  8. Real-World Applications
  9. Conclusion and Next Steps

The Problem That Started It All

Picture this: You’re staring at a Nigerian bank account number like 2256475832, and you’re wondering which bank it belongs to. You could Google it, ask someone, or… you could dive deep into the Central Bank of Nigeria’s technical documentation and figure it out yourself.

Guess which route I chose? 🤓

I had been working with the Nigeria Uniform Bank Account Number (NUBAN) system and had successfully reverse-engineered the check digit validation algorithm from the CBN’s 2019 standards document. Everything was working perfectly - I could validate account numbers like a pro. But there was one piece missing: identifying which bank an account number belonged to without prior knowledge.

That’s when I decided to turn this into a proper reverse engineering challenge.

Understanding NUBAN: The Foundation

Before we dive into the code, let’s understand what we’re working with. The Nigeria Uniform Bank Account Number (NUBAN) was introduced by the Central Bank of Nigeria (CBN) in 2010 to standardize bank account numbering across all Nigerian banks.

The NUBAN Structure

A NUBAN consists of exactly 16 digits with this structure:

ABCDEF-GHIJKLMNO-P

Where:

  • ABCDEF (6 digits): Bank identifier
    • For DMBs (Deposit Money Banks): 3-digit bank code + 3 leading zeros
    • For OFIs (Other Financial Institutions): Leading ‘9’ + 5-digit institution code
  • GHIJKLMNO (9 digits): Account serial number
  • P (1 digit): Check digit for validation

What Customers See vs. What’s Behind the Scenes

Here’s the interesting part: customers typically see only the last 10 digits of the NUBAN (the 9-digit serial number + check digit). The bank identifier is usually hidden from the customer interface but is crucial for inter-bank transactions.

For example:

  • Full NUBAN: 0000582256475832
  • Customer sees: 2256475832
  • Bank code: 058 (Guaranty Trust Bank)
  • Account serial: 225647583
  • Check digit: 2

The Algorithm Deep Dive

The heart of the NUBAN system is its check digit algorithm. Let’s break it down step by step.

Step 1: The Weighted Sum Calculation

The algorithm uses a specific weight pattern applied to the first 15 digits:

const weights = [3, 7, 3, 3, 7, 3, 3, 7, 3, 3, 7, 3, 3, 7, 3];

Each digit is multiplied by its corresponding weight, then all products are summed.

Step 2: Modulo 10 Operation

The sum is divided by 10, and we take the remainder (modulo operation).

Step 3: Check Digit Calculation

The check digit is calculated as 10 - remainder. If the remainder is 0, the check digit is 0.

Complete Algorithm Implementation

Here’s the core algorithm in JavaScript:

function calculateNubanCheckDigit(accountNumber) {
  const weights = [3, 7, 3, 3, 7, 3, 3, 7, 3, 3, 7, 3, 3, 7, 3];

  // Convert to 15-digit string (padded with leading zeros if needed)
  const digits = accountNumber.toString().padStart(15, "0").split("").map(Number);

  // Calculate weighted sum
  let sum = 0;
  for (let i = 0; i < 15; i++) {
    sum += digits[i] * weights[i];
  }

  // Calculate modulo 10
  const remainder = sum % 10;

  // Calculate check digit
  return remainder === 0 ? 0 : 10 - remainder;
}

Let’s Test It with a Known Example

From the CBN documentation, let’s verify with their example:

  • Bank code: 011 (becomes 000011)
  • Account serial: 000001457
  • Expected check digit: 9
const testAccount = "000011000001457";
const calculatedCheckDigit = calculateNubanCheckDigit(testAccount);
console.log(calculatedCheckDigit); // Should output: 9

Building the Solution

Now comes the fun part - reverse engineering. Since I had account numbers but didn’t know which banks they belonged to, I needed to work backwards.

The Reverse Engineering Strategy

The approach was simple yet effective:

  1. Extract the check digit from the given account number
  2. Extract the account serial number
  3. Try every possible bank code (000-999)
  4. For each bank code, calculate what the check digit should be
  5. If the calculated check digit matches the actual check digit, we found a match!

Implementation: Bank Code Discovery

Here’s the complete solution:

// List of known Nigerian bank codes
const bankCodes = [
  { code: "011", name: "First Bank of Nigeria" },
  { code: "032", name: "Union Bank of Nigeria" },
  { code: "033", name: "United Bank for Africa" },
  { code: "044", name: "Access Bank" },
  { code: "050", name: "Ecobank Nigeria" },
  { code: "057", name: "Zenith Bank" },
  { code: "058", name: "Guaranty Trust Bank" },
  { code: "070", name: "Fidelity Bank" },
  { code: "076", name: "Polaris Bank" },
  { code: "082", name: "Keystone Bank" },
  { code: "214", name: "First City Monument Bank" },
  { code: "221", name: "Stanbic IBTC Bank" },
  { code: "232", name: "Sterling Bank" },
  // Add more as needed
];

function findBankFromAccountNumber(accountNumber) {
  // Extract check digit (last digit)
  const checkDigit = parseInt(accountNumber.slice(-1));

  // Extract account serial (9 digits before check digit)
  const accountSerial = accountNumber.slice(-10, -1).padStart(9, "0");

  const matches = [];

  // Try all possible bank codes (000-999)
  for (let code = 0; code <= 999; code++) {
    const bankCode = code.toString().padStart(3, "0");
    const bankCodeWithLeadingZeros = bankCode.padStart(6, "0");

    // Construct the full 15-digit number
    const fullAccountNumber = bankCodeWithLeadingZeros + accountSerial;

    // Calculate expected check digit
    const expectedCheckDigit = calculateNubanCheckDigit(fullAccountNumber);

    // Check if it matches
    if (expectedCheckDigit === checkDigit) {
      const knownBank = bankCodes.find((bank) => bank.code === bankCode);
      matches.push({
        bankCode,
        bankName: knownBank ? knownBank.name : "Unknown Bank",
        isKnownBank: !!knownBank,
        fullNuban: bankCodeWithLeadingZeros + accountSerial + checkDigit,
      });
    }
  }

  return matches;
}

The Reverse Engineering Process

Let me walk you through the actual reverse engineering process using the account numbers I was given: 2256475832 and 0773623602.

Test Case 1: Account Number 2256475832

const account1 = "2256475832";
const results1 = findBankFromAccountNumber(account1);
console.log("Results for", account1, ":", results1);

Analysis:

  • Account Serial: 225647583
  • Check Digit: 2
  • Testing bank codes…

Results:

[
  {
    bankCode: "058",
    bankName: "Guaranty Trust Bank",
    isKnownBank: true,
    fullNuban: "0000582256475832",
  },
  {
    bankCode: "393",
    bankName: "Unknown Bank",
    isKnownBank: false,
    fullNuban: "0003932256475832",
  },
  // ... other mathematical matches
];

Winner: Guaranty Trust Bank (058) - the only recognized bank among the matches!

Test Case 2: Account Number 0773623602

const account2 = "0773623602";
const results2 = findBankFromAccountNumber(account2);
console.log("Results for", account2, ":", results2);

Analysis:

  • Account Serial: 077362360
  • Check Digit: 2
  • Testing bank codes…

Results:

[
  {
    bankCode: "033",
    bankName: "United Bank for Africa",
    isKnownBank: true,
    fullNuban: "0000330773623602",
  },
  {
    bankCode: "132",
    bankName: "Unknown Bank",
    isKnownBank: false,
    fullNuban: "0001320773623602",
  },
  // ... other mathematical matches
];

Winner: United Bank for Africa (033) - again, the only recognized bank!

Testing and Validation

To ensure our solution is robust, let’s add some validation features:

Input Validation

function validateAccountNumber(accountNumber) {
  // Remove any spaces or dashes
  const cleanNumber = accountNumber.replace(/[\s-]/g, "");

  // Check if it's exactly 10 digits
  if (!/^\d{10}$/.test(cleanNumber)) {
    throw new Error("Account number must be exactly 10 digits");
  }

  return cleanNumber;
}

Enhanced Bank Finder with Validation

function findBankFromAccountNumberWithValidation(accountNumber) {
  try {
    const validatedNumber = validateAccountNumber(accountNumber);
    const matches = findBankFromAccountNumber(validatedNumber);

    // Filter to show only known banks first
    const knownBanks = matches.filter((match) => match.isKnownBank);
    const unknownBanks = matches.filter((match) => !match.isKnownBank);

    return {
      accountNumber: validatedNumber,
      knownBanks,
      unknownBanks,
      totalMatches: matches.length,
    };
  } catch (error) {
    return {
      error: error.message,
      accountNumber: accountNumber,
    };
  }
}

Building Your Own NUBAN Tool

Want to build your own version? Here’s a complete, production-ready implementation:

Complete NUBAN Utility Class

class NubanUtility {
  constructor() {
    this.weights = [3, 7, 3, 3, 7, 3, 3, 7, 3, 3, 7, 3, 3, 7, 3];
    this.bankCodes = [
      { code: "011", name: "First Bank of Nigeria" },
      { code: "032", name: "Union Bank of Nigeria" },
      { code: "033", name: "United Bank for Africa" },
      { code: "044", name: "Access Bank" },
      { code: "050", name: "Ecobank Nigeria" },
      { code: "057", name: "Zenith Bank" },
      { code: "058", name: "Guaranty Trust Bank" },
      { code: "070", name: "Fidelity Bank" },
      { code: "076", name: "Polaris Bank" },
      { code: "082", name: "Keystone Bank" },
      { code: "214", name: "First City Monument Bank" },
      { code: "221", name: "Stanbic IBTC Bank" },
      { code: "232", name: "Sterling Bank" },
      { code: "215", name: "Unity Bank" },
      { code: "035", name: "Wema Bank" },
      { code: "068", name: "Standard Chartered Bank" },
      { code: "030", name: "Heritage Bank" },
      { code: "023", name: "Citibank Nigeria" },
    ];
  }

  calculateCheckDigit(accountNumber) {
    const digits = accountNumber.toString().padStart(15, "0").split("").map(Number);
    let sum = 0;

    for (let i = 0; i < 15; i++) {
      sum += digits[i] * this.weights[i];
    }

    const remainder = sum % 10;
    return remainder === 0 ? 0 : 10 - remainder;
  }

  validateAccountNumber(accountNumber) {
    const cleanNumber = accountNumber.replace(/[\s-]/g, "");

    if (!/^\d{10}$/.test(cleanNumber)) {
      throw new Error("Account number must be exactly 10 digits");
    }

    return cleanNumber;
  }

  findBank(accountNumber) {
    const validatedNumber = this.validateAccountNumber(accountNumber);
    const checkDigit = parseInt(validatedNumber.slice(-1));
    const accountSerial = validatedNumber.slice(-10, -1).padStart(9, "0");

    const matches = [];

    for (let code = 0; code <= 999; code++) {
      const bankCode = code.toString().padStart(3, "0");
      const bankCodeWithLeadingZeros = bankCode.padStart(6, "0");
      const fullAccountNumber = bankCodeWithLeadingZeros + accountSerial;
      const expectedCheckDigit = this.calculateCheckDigit(fullAccountNumber);

      if (expectedCheckDigit === checkDigit) {
        const knownBank = this.bankCodes.find((bank) => bank.code === bankCode);
        matches.push({
          bankCode,
          bankName: knownBank ? knownBank.name : "Unknown Bank",
          isKnownBank: !!knownBank,
          fullNuban: bankCodeWithLeadingZeros + accountSerial + checkDigit,
        });
      }
    }

    return {
      accountNumber: validatedNumber,
      knownBanks: matches.filter((m) => m.isKnownBank),
      unknownBanks: matches.filter((m) => !m.isKnownBank),
      totalMatches: matches.length,
    };
  }

  generateAccountNumber(bankCode, accountSerial) {
    const bankCodeWithLeadingZeros = bankCode.padStart(6, "0");
    const serialWithPadding = accountSerial.toString().padStart(9, "0");
    const fullAccountNumber = bankCodeWithLeadingZeros + serialWithPadding;
    const checkDigit = this.calculateCheckDigit(fullAccountNumber);

    return {
      fullNuban: fullAccountNumber + checkDigit,
      customerView: serialWithPadding + checkDigit,
      bankCode,
      accountSerial: serialWithPadding,
      checkDigit,
    };
  }
}

Usage Examples

// Create an instance
const nuban = new NubanUtility();

// Find bank from account number
const result1 = nuban.findBank("2256475832");
console.log("Bank for 2256475832:", result1.knownBanks[0]?.bankName);

const result2 = nuban.findBank("0773623602");
console.log("Bank for 0773623602:", result2.knownBanks[0]?.bankName);

// Generate a new account number
const newAccount = nuban.generateAccountNumber("058", "123456789");
console.log("New GTB account:", newAccount.customerView);

Real-World Applications

This reverse engineering approach has several practical applications:

1. Payment System Integration

// Validate account before processing payment
function processPayment(accountNumber, amount) {
  const nuban = new NubanUtility();
  const result = nuban.findBank(accountNumber);

  if (result.knownBanks.length === 0) {
    throw new Error("Invalid or unknown bank account");
  }

  const bank = result.knownBanks[0];
  console.log(`Processing ${amount} to ${bank.bankName}`);
  // ... payment processing logic
}

2. Account Validation Service

// API endpoint for account validation
app.post("/validate-account", (req, res) => {
  const { accountNumber } = req.body;
  const nuban = new NubanUtility();

  try {
    const result = nuban.findBank(accountNumber);
    res.json({
      valid: result.knownBanks.length > 0,
      bank: result.knownBanks[0]?.bankName,
      accountNumber: result.accountNumber,
    });
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

3. Data Migration and Cleanup

// Clean up account data in database
function cleanupAccountData(accounts) {
  const nuban = new NubanUtility();

  return accounts.map((account) => {
    try {
      const result = nuban.findBank(account.number);
      return {
        ...account,
        bank: result.knownBanks[0]?.bankName || "Unknown",
        isValid: result.knownBanks.length > 0,
      };
    } catch (error) {
      return {
        ...account,
        bank: "Invalid",
        isValid: false,
        error: error.message,
      };
    }
  });
}

Performance Considerations

When implementing this in production, consider these optimizations:

1. Caching Results

class NubanUtilityWithCache extends NubanUtility {
  constructor() {
    super();
    this.cache = new Map();
  }

  findBank(accountNumber) {
    const key = this.validateAccountNumber(accountNumber);

    if (this.cache.has(key)) {
      return this.cache.get(key);
    }

    const result = super.findBank(accountNumber);
    this.cache.set(key, result);

    return result;
  }
}

2. Early Termination

findBankOptimized(accountNumber) {
  const validatedNumber = this.validateAccountNumber(accountNumber);
  const checkDigit = parseInt(validatedNumber.slice(-1));
  const accountSerial = validatedNumber.slice(-10, -1).padStart(9, '0');

  // Try known banks first
  for (const bank of this.bankCodes) {
    const bankCodeWithLeadingZeros = bank.code.padStart(6, '0');
    const fullAccountNumber = bankCodeWithLeadingZeros + accountSerial;
    const expectedCheckDigit = this.calculateCheckDigit(fullAccountNumber);

    if (expectedCheckDigit === checkDigit) {
      return {
        bankCode: bank.code,
        bankName: bank.name,
        isKnownBank: true,
        fullNuban: bankCodeWithLeadingZeros + accountSerial + checkDigit
      };
    }
  }

  // If no known bank found, return null or continue brute force
  return null;
}

Conclusion and Next Steps

What started as a simple question - “Which bank does this account belong to?” - turned into a fascinating journey through financial algorithms, reverse engineering, and practical problem-solving.

Key Takeaways

  1. Documentation is Gold: The CBN’s NUBAN standards document was comprehensive enough to reverse engineer the entire system.

  2. Brute Force Sometimes Works: When dealing with a finite search space (1000 possible bank codes), brute force can be surprisingly effective.

  3. Validation is Crucial: Always validate inputs and handle edge cases in production code.

  4. Understanding the Why: Knowing why the algorithm works helps you build more robust solutions.

Next Steps and Improvements

Here are some ideas for extending this work:

  1. Add Support for OFIs: Implement the logic for Other Financial Institutions (starts with ‘9’).

  2. Build a Web Interface: Create a simple web app for bank identification.

  3. Add More Banks: Expand the bank codes database with more Nigerian banks.

  4. Sort Code Implementation: Add support for NUBAN sort codes.

  5. Batch Processing: Add support for processing multiple account numbers.

Final Thoughts

This project reminded me why I love programming - the ability to turn curiosity into code, to solve problems that seem insurmountable, and to share knowledge that others can build upon.

The NUBAN system is a great example of well-designed financial infrastructure. It’s simple enough to understand, robust enough for production use, and clever enough to prevent common errors.

Whether you’re a fintech developer, a curious programmer, or someone who just loves a good algorithm, I hope this deep dive into NUBAN reverse engineering has been educational and inspiring.


Have you ever reverse-engineered a system just because you could? Share your experiences with me here!

Resources


This blog post is part of my series on financial technology and algorithm exploration. Follow me for more deep dives into the fascinating world of fintech!