Skip to content

Business Email Compromise & Invoice Fraud

💡
Before you start

Python 3 and a terminal. No bank, supplier or payment system is contacted, and no real account number appears below. macOS and Linux include Python; on Windows install it from python.org with “Add python.exe to PATH” ticked, then check with python3 --version.

Every IBAN here is either a published documentation example or one computed on the spot for a fictional company. “Northwind Components” and “Brightpath Supplies” do not exist, and the telephone numbers use the ranges reserved for fiction. You need no accounting knowledge: the finance vocabulary you need is defined as it appears.

The Most Expensive Email You Will Ever Read

Business email compromise is fraud in which an attacker convinces someone with payment authority to send money to the wrong bank account. There is usually no malware, no exploit, and nothing for antivirus software to find. The email is often genuinely well written, arrives in an existing conversation, and asks for something the recipient was already expecting to do.

The FBI's Internet Crime Complaint Center attributed roughly $3 billion in reported losses to business email compromise in 2025 (FBI IC3 2025 Internet Crime Report, accessed 5 September 2026). Unlike ransomware, it leaves no encrypted files and no obvious incident -- the first sign is usually a supplier politely asking why they have not been paid, weeks after the money left.

💡
Not just a large-company problem.

Small businesses, sole traders, charities, schools and people buying a house are all heavily targeted. Any organisation that pays invoices by bank transfer is in scope, and smaller organisations are attractive precisely because they rarely have a second approver.

The Five Standard Variants

  • Supplier invoice fraud -- A real supplier's real invoice arrives with altered bank details, or a follow-up email announces "our new account". This is the highest-value variant because the amounts are already normal for your business
  • Executive impersonation -- A message that appears to come from a director asks for an urgent confidential transfer, often while they are conveniently travelling and unreachable
  • Payroll diversion -- An employee emails HR to update their bank details before payday. Low value per incident, very high success rate, and often noticed only when the real employee reports missing pay
  • Conveyancing and completion fraud -- Property deposits redirected during a house purchase. Frequently life-changing sums with no realistic recovery
  • Gift card requests -- The low-effort version, aimed at new or junior staff. Crude, but it still works often enough to be worth an attacker's time

Thread Hijacking: Why It Reads So Well

The dangerous version of BEC does not start with a message from a stranger. It starts with an attacker already reading a real mailbox -- usually one belonging to a supplier, not to you. Credentials for that mailbox typically come from an earlier phishing page or from infostealer malware on someone's laptop.

From inside, the attacker does not act immediately. They watch. They learn who signs off payments, what the invoice cycle looks like, how people write to each other, and which conversations involve money. Then they reply inside an existing thread, quoting the real history above their message.

  • The conversation is genuine -- everything above the new reply really happened, which is why it survives a skim
  • The timing is correct -- the request lands exactly when an invoice was genuinely due
  • The tone matches -- it was learned from months of real messages, so advice about spotting bad grammar does not help
  • Mailbox rules hide the truth -- attackers commonly create a rule in the compromised account that files replies from you into an unused folder, so the real owner never sees the exchange
  • The domain is nearly right -- if they cannot use the real mailbox, they register a lookalike domain: a swapped letter, an added hyphen, .co instead of .com
⚠️
The compromised mailbox is usually not yours.

Your own security can be excellent and you can still be defrauded through a supplier's breached inbox. This is why the control has to be a verification habit on your side, not an assumption that trusted contacts are safe.

The Signals That Actually Matter

Forget spelling mistakes. These are the signals that survive a competent attacker.

  • Any change of bank details, ever -- this is the single highest-value red flag in the whole category. Legitimate account changes are rare; fraudulent ones are routine
  • Pressure not to use the normal process -- urgency, confidentiality, "do not mention this to the team yet", or a request that arrives just before a weekend or holiday
  • The reply-to does not match the from -- a display name can say anything. Expand the actual address and read it character by character
  • A new contact appears mid-deal -- "I am taking over this account from my colleague" is a standard pivot to a mailbox the attacker controls
  • The request cannot be verified on the channel it arrived on -- if the only way to confirm is to reply to that email, treat it as unverified

Verification That Works

One control prevents the majority of losses in this category, and it is not technical.

1
Call back on a number you already held.

Use the number from the signed contract, a previous statement, or your own records -- never a number in the email requesting the change. Attackers include their own phone number precisely because they expect a careful person to call.

2
Verify bank changes with a named human, out loud.

Not by email, not by replying, not by text. Ask them to read the full account details back to you and confirm them against what you received.

3
Send a small test payment first.

For any genuinely new account, transfer a token amount and confirm receipt with the supplier before releasing the full sum. The delay costs a day; the alternative can cost the business.

4
Require two people above a threshold.

Dual authorisation for payments over an agreed amount, and for every change of bank details regardless of amount. The second person must verify independently, not simply approve because the first one asked.

💡
Make it a rule, not a judgement call.

If verification is optional, it will be skipped under pressure -- which is exactly the condition the attacker engineers. Write it down as policy so that following it is never an accusation against the person being verified.

Technical Controls Worth the Effort

Process stops the loss; these reduce how often you are targeted in the first place.

  • Phishing-resistant MFA on all mail accounts -- passkeys or hardware security keys. SMS codes can be intercepted, and stolen session cookies bypass MFA entirely
  • SPF, DKIM and DMARC on your domain -- with DMARC at enforcement, not merely monitoring. This stops others receiving mail that forges your domain
  • Alert on new mailbox forwarding rules -- attacker-created rules that hide replies are one of the most reliable indicators of a compromised account
  • External-sender banners -- a visible marker on mail from outside the organisation makes lookalike domains far easier to notice
  • Register the obvious lookalike domains -- cheap insurance against the most common typosquats of your own name
  • Review sign-in logs for impossible travel -- a login from two distant countries within an hour is worth investigating the same day

If a Payment Has Already Gone

The first few hours decide whether the money is recoverable.

  • Call your bank now and ask for a recall -- funds are frequently moved on within hours of arriving, so same-day action is what makes recovery possible
  • Report it to national cybercrime authorities -- in the US, ic3.gov, whose recovery asset team can request freezes on domestic accounts when reports arrive quickly
  • Force a password reset and revoke every active session on any mailbox that may be compromised -- a password change alone does not end a session an attacker is already inside
  • Look for hidden mailbox rules and delete them, then check whether any other conversations were tampered with
  • Warn your suppliers and customers -- if your mailbox was the compromised one, their payments are being targeted next
  • Notify your insurer and preserve the evidence -- full headers, the original messages, and the payment references

Catch an Invoice Redirection Before the Transfer, in Five Steps

Invoice fraud is not a technical break-in. Nothing is hacked, no malware runs, and every system behaves exactly as designed — which is why it survives firewalls, antivirus and staff training alike. What happens is that a genuine payment is sent to a different account, by a real member of staff, following a real process. In the next half hour you will build the checks that catch it: the checksum that everybody trusts and that proves nothing, the domain trick that survives being stared at, and the one comparison that actually stops the payment. Every line of output below came from running these files.

1
Build the checksum that every bank account number carries

Go: open a terminal in a folder you can write to — cd ~/Desktop on macOS or Linux, cd %USERPROFILE%\Desktop on Windows.

Do: save this as iban.py and run python3 iban.py. The four test cases include the example published in the IBAN registry itself, so you are checking your code against a known-correct answer rather than against your own expectations.

"""ISO 7064 mod-97-10: the checksum every IBAN carries."""

def valid(iban):
    s = iban.replace(" ", "").upper()
    s = s[4:] + s[:4]                      # move the country+check digits to the end
    digits = "".join(str(ord(c) - 55) if c.isalpha() else c for c in s)
    return int(digits) % 97 == 1

# The example published in the IBAN registry itself -- a known-good test vector.
TESTS = [
    ("GB82 WEST 1234 5698 7654 32", True,  "the registry's own example"),
    ("GB82 WEST 1234 5698 7654 33", False, "same, last digit changed"),
    ("DE89 3704 0044 0532 0130 00", True,  "a valid German IBAN"),
    ("FI21 1234 5600 0007 85",      True,  "a valid Finnish IBAN"),
]

for iban, expected, note in TESTS:
    got = valid(iban)
    mark = "ok " if got == expected else "!! "
    print("%s%-28s %-5s  %s" % (mark, iban, "valid" if got else "INVALID", note))

print()
print("The checksum catches typos. That is all it was designed to do.")

You should see: the registry's example accepted, and the same number with one digit changed rejected:

ok GB82 WEST 1234 5698 7654 32  valid  the registry's own example
ok GB82 WEST 1234 5698 7654 33  INVALID  same, last digit changed
ok DE89 3704 0044 0532 0130 00  valid  a valid German IBAN
ok FI21 1234 5600 0007 85       valid  a valid Finnish IBAN

The checksum catches typos. That is all it was designed to do.

Every line begins ok, meaning the result matched what that row expected — including the row that expected a rejection. A test suite in which nothing is ever supposed to fail has not been tested at all.

If not: a line beginning !! means your result disagreed with the expected one; the usual cause is a copying error in the long digit strings. ValueError: invalid literal for int() means a non-alphanumeric character survived the copy — the replace(" ", "") strips spaces but not other punctuation.

2
Produce a perfectly valid account number for the attacker

Go: the same folder. This is the step that removes a false sense of safety.

Do: save this as forge.py and run python3 forge.py. It computes correct check digits for an arbitrary account, which is what any bank does when it opens one.

"""Anyone can produce a VALID IBAN for an account they just opened."""

def valid(iban):
    s = iban.replace(" ", "").upper()
    s = s[4:] + s[:4]
    digits = "".join(str(ord(c) - 55) if c.isalpha() else c for c in s)
    return int(digits) % 97 == 1

def check_digits(country, bban):
    s = bban + country + "00"
    digits = "".join(str(ord(c) - 55) if c.isalpha() else c for c in s.upper())
    return "%02d" % (98 - int(digits) % 97)

# An account the attacker opened this morning, in his own name, at a real bank.
country, bban = "LT", "601010012345678901"
attacker = country + check_digits(country, bban) + bban

print("attacker's IBAN :", attacker)
print("checksum valid  :", valid(attacker))
print()
print("Compare with the vendor's real one:")
real = "GB82WEST12345698765432"
print("vendor's IBAN   :", real)
print("checksum valid  :", valid(real))
print()
print("Both pass. The checksum answers 'is this a well-formed account number'.")
print("It never answers 'does this account belong to the person you are paying'.")

You should see: two account numbers, both passing the same check:

attacker's IBAN : LT15601010012345678901
checksum valid  : True

Compare with the vendor's real one:
vendor's IBAN   : GB82WEST12345698765432
checksum valid  : True

Both pass. The checksum answers 'is this a well-formed account number'.
It never answers 'does this account belong to the person you are paying'.

The checksum was designed to catch a mistyped digit, and it does that well. It was never designed to say anything about ownership, and it cannot. An attacker does not forge an IBAN — he opens an ordinary account, in a real bank, often in his own name or a mule's, and its number is valid because it is real. Accounting software that validates the format and shows a green tick has confirmed only that the number is well formed.

If not: if checksum valid prints False for the attacker's IBAN, the check_digits function received the country code in lower case — the .upper() call inside it is doing real work. If you see NameError: name 'valid' is not defined, the valid function was left out when copying; this file is deliberately standalone and needs its own copy.

3
Watch the attacker join a conversation already in progress

Go: the same folder.

Do: save this as thread.py and run python3 thread.py. This is a four-message invoice thread; three messages are genuine.

"""Reply-chain hijack: the attacker joins a conversation already in progress."""

THREAD = [
    ("Mon 09:12", "accounts@northwind-components.co.uk", "Invoice 4471 attached, due 30 days"),
    ("Mon 14:03", "j.hart@brightpath-supplies.com",      "Received, thanks. Approved for payment."),
    ("Tue 10:41", "accounts@northwind-components.co.uk", "Any date for the transfer?"),
    ("Wed 08:55", "accounts@northwind-cornponents.co.uk", "Our bank changed -- new details below"),
]

known_good = "northwind-components.co.uk"

for when, sender, subject in THREAD:
    domain = sender.split("@")[1]
    flag = "" if domain == known_good or domain == "brightpath-supplies.com" else "  <-- NOT the known domain"
    print("%s  %-38s %s%s" % (when, sender, subject, flag))

print()
bad = THREAD[3][1].split("@")[1]
first = next(i for i, (a, b) in enumerate(zip(known_good, bad)) if a != b)
print("known good  :", known_good)
print("this message:", bad)
print("             ", " " * first + "^ first difference, character %d" % (first + 1))
print("length       : %d vs %d" % (len(known_good), len(bad)))
print()
print("'m' became 'rn'. At normal size, in a reply chain you have been")
print("reading all week, nobody re-reads the address.")

You should see: the fourth message arriving from a domain that is one character different:

Mon 09:12  accounts@northwind-components.co.uk    Invoice 4471 attached, due 30 days
Mon 14:03  j.hart@brightpath-supplies.com         Received, thanks. Approved for payment.
Tue 10:41  accounts@northwind-components.co.uk    Any date for the transfer?
Wed 08:55  accounts@northwind-cornponents.co.uk   Our bank changed -- new details below  <-- NOT the known domain

known good  : northwind-components.co.uk
this message: northwind-cornponents.co.uk
                          ^ first difference, character 13
length       : 26 vs 27

'm' became 'rn'. At normal size, in a reply chain you have been
reading all week, nobody re-reads the address.

Look at the two domains printed one above the other, then imagine them in a mail app at ordinary size, in a thread you have been reading since Monday, quoted beneath your own reply. The letter m has been replaced by r followed by n. There are dozens of these pairs — rn/m, vv/w, l/I, 0/O — and they are chosen because the eye reads word shapes rather than letters.

This is why “check the sender carefully” is not a defence. It asks a human to do reliably, hundreds of times a week, something they are reliably bad at once.

If not: if the caret sits under the wrong character, your terminal is rendering the leading spaces differently — the character number printed on the same line is the authoritative answer. If the flag appears on more than one row, a domain string was altered when copying.

4
Compare the request against your own records instead of reading it

Go: the same folder. This is the control that works.

Do: save this as ledger.py and run python3 ledger.py.

"""The control that actually stops this: compare against your OWN record."""

# Written when the supplier was onboarded, from a document you verified then.
ON_FILE = {
    "Northwind Components Ltd": {
        "iban": "GB82WEST12345698765432",
        "contact_phone": "+44 114 496 0188",
        "domain": "northwind-components.co.uk",
    },
}

def check_payment(vendor, iban_on_invoice, sender_domain):
    record = ON_FILE.get(vendor)
    if not record:
        return ["vendor not on file -- onboard before paying"]
    problems = []
    if iban_on_invoice != record["iban"]:
        problems.append("BANK DETAILS CHANGED (invoice %s vs file %s)"
                        % (iban_on_invoice[:8] + "...", record["iban"][:8] + "..."))
    if sender_domain != record["domain"]:
        problems.append("SENDER DOMAIN CHANGED (%s vs %s)" % (sender_domain, record["domain"]))
    return problems or ["matches the record -- pay"]

print("Invoice 4471, as it arrived on Wednesday:")
for line in check_payment("Northwind Components Ltd",
                          "LT15601010012345678901",
                          "northwind-cornponents.co.uk"):
    print("   ", line)

print()
print("The same invoice, if nothing had been tampered with:")
for line in check_payment("Northwind Components Ltd",
                          "GB82WEST12345698765432",
                          "northwind-components.co.uk"):
    print("   ", line)

You should see: both changes caught, without anybody having to notice anything:

Invoice 4471, as it arrived on Wednesday:
    BANK DETAILS CHANGED (invoice LT156010... vs file GB82WEST...)
    SENDER DOMAIN CHANGED (northwind-cornponents.co.uk vs northwind-components.co.uk)

The same invoice, if nothing had been tampered with:
    matches the record -- pay

Nothing here depends on alertness, mood, workload or how convincing the email was. The check is a comparison between the incoming request and a record captured when the relationship began — and the answer is the same at 9 a.m. on Monday and 5 p.m. on Friday. That is the property to look for in any control: it must not degrade when the person applying it is tired.

Note also which fields it compares. Not the tone of the email, not the plausibility of the explanation, not whether the invoice looks right — the account number and the sending domain, both of which are exact and neither of which is a judgement.

If not: if it prints vendor not on file, the vendor name string differs from the key in ON_FILE — it must match exactly, including “Ltd”. That strictness is deliberate: a fuzzy vendor match is precisely how a near-miss name slips through.

5
Decide which telephone number you are allowed to ring

Go: the same folder.

Do: save this as callback.py and run python3 callback.py.

"""Which number do you ring? The answer decides whether the control works."""

ON_FILE_PHONE   = "+44 114 496 0188"    # captured at onboarding, from your own records
INVOICE_PHONE   = "+44 114 496 0231"    # printed on the invoice that asks for the change
EMAIL_SIGNATURE = "+44 114 496 0231"    # in the signature of the same email

def verify_change(number_you_ring):
    if number_you_ring == ON_FILE_PHONE:
        return "REAL VENDOR ANSWERS: 'We have not changed banks.' -> change refused"
    return "THE ATTACKER ANSWERS: 'Yes, that's us, please go ahead.' -> change accepted"

for label, number in [("the number on the invoice", INVOICE_PHONE),
                      ("the number in the signature", EMAIL_SIGNATURE),
                      ("the number in YOUR vendor file", ON_FILE_PHONE)]:
    print("%-32s %s" % (label, number))
    print("   ", verify_change(number))

print()
print("Two of the three numbers came with the request. Ringing either of")
print("them asks the attacker to confirm his own instruction.")
print()
print("Rule: a change of bank details is verified on the contact details you")
print("held BEFORE the request arrived -- never on any detail inside it.")

You should see: two of the three numbers reaching the attacker:

the number on the invoice        +44 114 496 0231
    THE ATTACKER ANSWERS: 'Yes, that's us, please go ahead.' -> change accepted
the number in the signature      +44 114 496 0231
    THE ATTACKER ANSWERS: 'Yes, that's us, please go ahead.' -> change accepted
the number in YOUR vendor file   +44 114 496 0188
    REAL VENDOR ANSWERS: 'We have not changed banks.' -> change refused

Two of the three numbers came with the request. Ringing either of
them asks the attacker to confirm his own instruction.

Rule: a change of bank details is verified on the contact details you
held BEFORE the request arrived -- never on any detail inside it.

“Ring the supplier to confirm” is the advice everybody gives and it is only half a rule. Confirming a suspicious request using a contact detail supplied by that request is not verification — it is asking the sender whether the sender is genuine, and a well-run fraud staffs that line.

The complete rule has three parts. A change of banking details is verified (1) by voice, not by email, (2) on a number held in your own records before the request arrived, and (3) by a second person, so no single account can both request and approve. Written down and applied without exception, that stops this attack even when every earlier check has been defeated.

If not: if all three lines report the change accepted, the constant ON_FILE_PHONE has been made equal to the invoice number — they must differ, which is the entire point of the file.

🎉
Check yourself before moving on

Without scrolling up: a supplier emails from their correct, verified domain — no lookalike, no spoofing, headers all clean — saying their bank has changed, and the new IBAN validates. Your colleague says there is nothing left to check. What is still missing, and why does it matter more here than in the lookalike case? Answer: the domain being genuine means the mailbox may have been taken over rather than imitated — a compromised account sends from the real domain and passes every technical check, because it is the real account. Step 2 showed the valid IBAN adds nothing, since attackers use real accounts. What is missing is the step no email can satisfy: voice confirmation on the number in your own vendor file, approved by a second person. It matters more here precisely because every automatic signal is clean — the lookalike case at least leaves a visible artefact, while a mailbox takeover leaves none, and the out-of-band callback is the only check that does not run on the channel the attacker controls.

Now do it without the page: extend ledger.py so it also refuses a payment when the invoice amount differs from the purchase order by more than a set tolerance, then add a second vendor and a payment that should pass. Once it works, ask the question the code cannot answer: in the organisation you actually work for or run, who is permitted to change a stored bank detail, and would anyone find out this week if they did? If you can change a supplier's account number by yourself, the control described in step 5 does not exist there yet.

Summary

  • BEC is a process failure, not a malware infection -- there is nothing for antivirus to catch
  • Thread hijacking makes the message genuine -- the conversation above the fraudulent reply really happened
  • A change of bank details is the red flag that outranks every other signal in this category
  • Verify by voice on a number you already held -- never one supplied in the request itself
  • Dual authorisation and test payments convert a catastrophic loss into a minor delay
  • Move within hours if it happens -- bank recall first, then a formal report, then containment of the mailbox
🎉
One policy line does most of the work.

"We never change payment details based on an email alone." Agree it, write it down, tell your suppliers you follow it, and apply it to the chief executive exactly as strictly as to everyone else.