How attackers turn a web app breach into euros — credential resale, payment data, account takeover, fraudulent transactions. The economics that drive defenses.
Attackers optimize for profit per hour. Defenses that don't change attacker economics don't reduce risk — they just shuffle which doors get opened. To build defenses that actually deter attacks, you need to understand what makes a compromise profitable and what makes it not.
This tutorial walks through the main monetization patterns for web app compromise and the specific defenses that disrupt each.
The most common monetization route, requires the least skill from the buyer.
A successful stuffing campaign returns 0.1%-1% of attempted credentials. The math works because compute is cheap and attempts are unlimited.
# Django example: enforce breached-password check on login
import hashlib
import requests
def is_breached(password: str) -> bool:
sha1 = hashlib.sha1(password.encode()).hexdigest().upper()
prefix, suffix = sha1[:5], sha1[5:]
r = requests.get(f"https://api.pwnedpasswords.com/range/{prefix}", timeout=2)
if r.status_code != 200:
return False # Fail open — don't lock users out on API issues
return any(line.startswith(suffix) for line in r.text.splitlines())
def login_view(request):
if request.method == 'POST':
password = request.POST['password']
if is_breached(password):
messages.error(request, 'This password appears in known breaches. Please use a different password.')
return redirect('password_reset')
# ...regular auth flow
The classic and still highly profitable. Attackers compromise either:
Attackers compromise either the host directly or a CDN/script you load. Inject JavaScript into the checkout page that copies form data and POSTs it to attacker-controlled infrastructure. The page works normally — customer pays, gets product. But their card is now stolen.
Famous victims: British Airways, Ticketmaster, Newegg. Each faced heavy remediation costs and regulatory action; the UK regulator fined British Airways £20m.
script-src 'self' plus explicit allowlist for CDNs.<script src="..." integrity="sha384-..."> ensures the loaded content matches the expected hash.# CSP middleware in Django settings
# django-csp 3.x setting names; django-csp 4.x moves these into the
# CONTENT_SECURITY_POLICY = {"DIRECTIVES": {...}} dict
CSP_DEFAULT_SRC = ("'self'",)
CSP_SCRIPT_SRC = ("'self'", "https://js.stripe.com", "https://cdn.jsdelivr.net")
CSP_STYLE_SRC = ("'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net")
CSP_FRAME_SRC = ("https://js.stripe.com",)
CSP_CONNECT_SRC = ("'self'", "https://api.stripe.com")
CSP_REPORT_URI = "/csp-report/"
The economics shift when the target is a specific account, not a bulk credential. Attackers take over:
The attack: targeted phishing, SIM swap to bypass SMS-MFA, OAuth consent phishing, or session token theft.
Attacker doesn't want your data. They want your CPU.
The miner often doesn't even need root — runs as the application user, indistinguishable from a busy worker process. Easy to miss until the cloud bill arrives.
Less obvious than the others but extremely lucrative.
Many web apps integrate with email — sending invoices, password resets, notifications, customer communications. Compromise of the app may give attackers:
The attacker uses this to send convincing emails to your customers' AP departments, requesting that future invoices be paid to an attacker-controlled bank account.
p=reject with monitoringA specific and growing pattern: your web app loads JS/CSS from a third-party CDN or package manager. The third party gets compromised. Your users get malicious code.
Recent examples:
Mapping defense priorities to threat types:
| Defense | Disrupts |
|---|---|
| MFA (phishing-resistant) | Credential stuffing, account takeover, BEC |
| Rate limiting on auth | Credential stuffing |
| Breached password blocking | Credential stuffing |
| CSP + SRI | Magecart, supply chain JS injection |
| DMARC | Email spoofing, BEC |
| Egress filtering | Cryptominer C2, data exfiltration |
| Dependency pinning + scanning | Supply chain |
| Audit logging + anomaly alerting | All categories — your detection layer |
The combination is multiplicative. Each defense alone deters some attackers; the stack deters the ones who'd otherwise persist.
Patterns in real-world Dutch SME web app breaches:
/admin/ as needing special protectionEach is preventable with the basics from tutorial 1 plus the disruptions in this tutorial. None require nation-state-tier defenses — just consistent execution of fundamentals.
Understanding how attackers turn a technical compromise into actual money is one of the most useful things a defender can learn, because it reveals what they are really after and therefore what to protect hardest. A vulnerability is only a means to an end, and the end is almost always financial — fraud, theft, ransom, or selling access and data. Tracing the path from an initial flaw to a payout shows you which assets and capabilities are the real targets, letting you concentrate defenses where the money is rather than spreading effort evenly across things that have very different value to an attacker.
Smaller organizations are frequently targeted precisely because they often lack the layered defenses and monitoring of larger ones, while still handling money and valuable data. The common failure points are predictable: weak authentication, unpatched systems, poor separation between functions, and no monitoring to catch fraud in progress. These gaps are what let a compromise translate into a financial loss before anyone notices. Recognizing where these weaknesses typically lie — and that being small is not the same as being an unattractive target — is the first step to closing the paths that turn an intrusion into a wire fraud or a drained account.
Because monetization is a chain of steps from compromise to cash-out, you can defend at multiple points, not just at the initial vulnerability. Strong authentication and patching reduce initial access; segmentation limits movement; transaction controls and anomaly detection catch fraud before it completes; and monitoring shortens the time an attacker has to act. Even if one defense fails, another can break the chain before money is lost. Thinking about security as disrupting the attacker's path to profit — rather than only preventing the first flaw — gives you many opportunities to stop an attack, and it focuses your effort on protecting the financial outcomes attackers are ultimately chasing.
The title of this tutorial names the most damaging chain for a small business, and it is also a very ordinary one. An injection flaw or a stolen admin session gives someone read and sometimes write access to the database. They use it to find customers, invoices and, above all, the stored bank details for payouts, refunds or supplier payments. Then they either change those details quietly or use the harvested invoice data to send convincing "our bank has changed" messages. The money leaves by a normal, authorised transfer. From the bank's side nothing looks wrong, because the payment was initiated by the legitimate owner of the account.
That last point shapes the whole defence. By the time money moves, every technical control has already been bypassed, and the only things left are business-process controls and how fast you respond. So you need controls at every stage, and each stage should leave a signal that someone actually watches. The table maps each stage to what a defender can observe and the control that breaks it.
| Attacker stage | What the defender can observe | Control that breaks the chain |
|---|---|---|
| Initial database access (injection, leaked credentials, stolen admin session) | Bursts of database syntax errors, new query shapes in pg_stat_statements, WAF anomaly scores, admin logins from new devices | ORM or parameterised queries only, least-privilege database roles, phishing-resistant MFA on admin |
| Harvesting customer, invoice and bank data | Unusually large result sets, reads of tables the app normally touches rarely, honeytoken records being accessed or contacted | Column-level privileges, encrypted or tokenised bank fields, honeytokens, egress filtering |
| Changing payout or supplier bank details | Bank-detail changes with no matching approved change request, changes outside business hours, several accounts changed to the same beneficiary | Change-request workflow with step-up auth, out-of-band confirmation, cooling-off period, database trigger audit |
| Social engineering customers with lookalike invoices | Spikes in transactional email volume, template edits, DMARC aggregate reports showing unknown senders | DMARC p=reject, template change audit, a published "we never change bank details by email" policy |
| Cash-out via a payout or wire | First payout to a new beneficiary, payouts just under approval thresholds, amounts well above the account's history | Velocity limits, holds on first payouts to new beneficiaries, dual approval, payee-name verification where your bank offers it |
| Laundering after the transfer | Usually nothing. It happens outside your systems. | A rehearsed response playbook: bank fraud contact within minutes, recall request, evidence preserved |
Treat the table as a gap analysis. For each row, ask which signal you would actually see today, who would see it, and how many hours it would take. A row with no observable signal is where to invest next.
If your application stores bank details for any reason (marketplace seller payouts, affiliate commissions, customer refunds, supplier records in an internal tool), changing those details is the most valuable write operation in the system. It should never be a normal UPDATE behind a model form. Model it as a request that has to pass several independent gates before it takes effect.
The trade-off is friction for legitimate users who really have switched banks. In practice, a clear message ("your new account will be used from Thursday; we have emailed your previous address") gets far fewer complaints than a diverted payout does. Let support shorten the delay manually after verifying the user through a separate channel, and log that override as its own audit event.
# billing/models.py
from django.conf import settings
from django.db import models
class PayoutAccountChange(models.Model):
class Status(models.TextChoices):
PENDING = "pending"
CANCELLED = "cancelled"
APPLIED = "applied"
account = models.ForeignKey(
"billing.PayoutAccount", on_delete=models.PROTECT, related_name="changes"
)
requested_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
new_iban = models.CharField(max_length=34)
new_holder_name = models.CharField(max_length=140)
status = models.CharField(max_length=10, choices=Status, default=Status.PENDING)
requested_at = models.DateTimeField(auto_now_add=True)
effective_at = models.DateTimeField()
applied_at = models.DateTimeField(null=True, blank=True)
request_ip = models.GenericIPAddressField(null=True)
# billing/services.py
import time
from datetime import timedelta
from django.db import transaction
from django.utils import timezone
from .audit import audit_event
from .models import PayoutAccount, PayoutAccountChange
from .notify import notify_previous_contacts
COOLING_OFF = timedelta(hours=48)
MAX_CHANGES_PER_30_DAYS = 1
STEP_UP_MAX_AGE_SECONDS = 300
class PayoutChangeRejected(Exception):
pass
def request_payout_change(request, account_id, new_iban, new_holder_name):
user = request.user
step_up_at = request.session.get("step_up_at") # set by your WebAuthn view
if step_up_at is None or time.time() - step_up_at > STEP_UP_MAX_AGE_SECONDS:
raise PayoutChangeRejected("step-up authentication required")
with transaction.atomic():
account = PayoutAccount.objects.select_for_update().get(pk=account_id, owner=user)
since = timezone.now() - timedelta(days=30)
if account.changes.filter(requested_at__gte=since).count() >= MAX_CHANGES_PER_30_DAYS:
audit_event(request, "payout_change.velocity_blocked", account=account)
raise PayoutChangeRejected("too many recent changes; contact support")
shared = PayoutAccount.objects.filter(iban=new_iban).exclude(pk=account.pk).exists()
change = PayoutAccountChange.objects.create(
account=account,
requested_by=user,
new_iban=new_iban,
new_holder_name=new_holder_name,
effective_at=timezone.now() + COOLING_OFF,
request_ip=request.META.get("REMOTE_ADDR"),
)
audit_event(request, "payout_change.requested", account=account,
change_id=change.pk, beneficiary_shared=shared)
# Contacts captured BEFORE this change, never the ones in the request.
transaction.on_commit(lambda: notify_previous_contacts(change.pk))
return change
A scheduled job applies changes whose effective_at has passed and that are still pending. It sets applied_at and writes payout_change.applied to the audit log. The payout code reads bank details only from PayoutAccount, so a pending change can never be paid out early. Two details matter. First, the select_for_update() row lock stops two concurrent requests from both passing the velocity check. Second, a beneficiary shared with another account should not be rejected automatically, because families and small companies do share accounts, but it should route the change to manual review. Behind a proxy, REMOTE_ADDR will be the proxy's address, so take the client IP from whatever trusted header your ingress sets.
The cooling-off period protects the details. A separate rule should protect the money: the first payout to a beneficiary that has never been paid before is held for manual review, or capped at a small amount, and any single payout well above the account's normal range needs a second approver. Store the per-account baseline (typical amount, usual frequency) and compare against it, rather than a single global threshold that attackers can learn and stay under.
An injection flaw with write access, or leaked database credentials, lets someone change a bank field directly and skip every gate described above. You cannot rely on application code to record an event the application never saw. A PostgreSQL trigger owned by a separate role closes that gap: every change to the column is recorded in a schema the application role cannot modify.
-- Run as a dedicated owner role, not as the application role.
CREATE SCHEMA audit;
CREATE TABLE audit.bank_detail_changes (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
changed_at timestamptz NOT NULL DEFAULT now(),
db_session text NOT NULL,
app_user_id text,
row_id bigint NOT NULL,
old_iban text,
new_iban text
);
CREATE FUNCTION audit.log_bank_change() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog AS $$
BEGIN
IF NEW.iban IS DISTINCT FROM OLD.iban THEN
INSERT INTO audit.bank_detail_changes (db_session, app_user_id, row_id, old_iban, new_iban)
VALUES (session_user, current_setting('app.user_id', true), NEW.id, OLD.iban, NEW.iban);
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER payoutaccount_bank_audit
AFTER UPDATE OF iban ON billing_payoutaccount
FOR EACH ROW EXECUTE FUNCTION audit.log_bank_change();
REVOKE ALL ON SCHEMA audit FROM PUBLIC;
GRANT USAGE ON SCHEMA audit TO monitoring;
GRANT SELECT ON audit.bank_detail_changes TO monitoring;
Inside a SECURITY DEFINER function, current_user is the function owner, so the trigger records session_user to capture the role that actually connected. The app.user_id setting is optional context. Django can set it per transaction with SELECT set_config('app.user_id', %s, true), but anything with SQL access can also set it, so treat it as a hint rather than evidence. The trigger only covers updates. Add a matching AFTER INSERT trigger if new payout accounts are just as sensitive in your model.
The tripwire only works once you reconcile it against the application's own record. Any database-level change with no matching applied change request is a write that bypassed the workflow, and that should page someone:
SELECT a.*
FROM audit.bank_detail_changes AS a
LEFT JOIN billing_payoutaccountchange AS c
ON c.account_id = a.row_id
AND c.new_iban = a.new_iban
AND c.status = 'applied'
AND a.changed_at BETWEEN c.applied_at - interval '5 minutes'
AND c.applied_at + interval '5 minutes'
WHERE c.id IS NULL
AND a.changed_at > now() - interval '1 day';
Run it from a monitoring job every few minutes, using the read-only monitoring role. Two cheap additions make the data-theft stage visible too. First, honeytoken records: a fake customer whose email address is a mailbox nobody else uses, and a fake supplier with a bank account that is never paid. Nothing legitimate reads them in bulk or contacts them, so a message arriving at that mailbox means your data has left the building. Second, column-level privileges: if only the payout service needs to read IBANs, grant SELECT on that column only to the payout service's role. The web role cannot then dump it, even through an injection flaw.
Audit logs are often the first thing an intruder with admin access tidies up, and the last thing a small team thinks to protect. For the monetisation chain, a good audit log answers four questions quickly: who changed money-related data, from where, through which path, and what did it look like before?
payout_change.requested, payout_change.cancelled, payout.approved, email_template.updated, api_key.created and mfa.removed as named events with structured fields.INSERT on the audit table but not UPDATE or DELETE, and ship events off the host (a log pipeline, object storage with retention locks, or a SIEM) within seconds.Most small teams cannot staff a security operations centre, so each alert has to earn its place. The following signals have a direct link to money and a low legitimate rate, so they suit paging rather than a dashboard nobody opens:
log_min_error_statement so the failing statements are logged and alert on the rate.Route these to a channel that reaches a human outside office hours. Fraud tends to be timed for evenings, weekends and holidays, when a transfer sits unnoticed longest.
When a customer reports an unpaid invoice they say they paid, or a seller says a payout never arrived, the clock is already running. Recovering funds depends mostly on how quickly the receiving bank is asked to freeze them. Write the playbook down now, with names and phone numbers, because nobody will find them calmly during the incident.
Attackers will always find the cheapest path to monetization. Your job is to make every path expensive enough that they move to another target. Perfect security is impossible. Sufficient deterrent is achievable.
Build defenses that change the economic calculation. Track them. Test them. The next eight tutorials show you how at the technical level.