What happens to your users' data after a web app is breached — the marketplaces, the buyers, the resale economy, and what you can do about it.
Knowing how attackers monetize stolen data shapes your defenses. If you understand that a breach leaks into specific marketplaces with specific buyer profiles, your incident response and threat intelligence work get sharper. This tutorial walks through the post-breach economy for web app data specifically — what's stolen, where it goes, who buys it, and how to monitor for your own exposure.
Different data types follow different markets:
Where stolen data gets sold:
Traditional marketplaces with reputation systems. Vendors and buyers anonymous to each other but accountable through escrow and reviews. Examples in the last decade: AlphaBay, Hansa, Hydra, Genesis Market, Russian Market.
Increasingly dominant for credential and access trade. Lower bar to participation than Tor. Channels with thousands of subscribers buying/selling/discussing in real time.
Long-running boards (some 15+ years old) where higher-tier criminals meet. Examples: Exploit.in, XSS.is, Verified.
Some platforms automate the whole flow. Buyer searches for compromised accounts at a specific company, pays in crypto, gets credentials delivered automatically. Genesis Market (taken down 2023) was the most famous example. Successors continue to emerge.
This is worth a dedicated section because it's a common and underestimated attack vector.
Tools that automate this: "Cookie Cloner," various stealer malware (RedLine, Vidar, Raccoon), browser extensions sold for the purpose.
MFA happens at login. Session cookies represent an already-authenticated session. Once issued, they're trusted until expiry. Attackers buy session cookies specifically to bypass MFA.
/admin/*, password change, payment method change all require fresh authentication.# settings.py
SESSION_COOKIE_AGE = 60 * 60 * 8 # 8 hours
SESSION_EXPIRE_AT_BROWSER_CLOSE = True # logout on browser close
SESSION_COOKIE_SECURE = True # HTTPS only
SESSION_COOKIE_HTTPONLY = True # No JS access
SESSION_COOKIE_SAMESITE = 'Strict' # CSRF defense
# Force re-auth for admin actions
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
def require_fresh_auth(max_age_seconds=600):
"""Require recent password verification within max_age_seconds."""
def decorator(view_func):
def wrapper(request, *args, **kwargs):
last_auth = request.session.get('last_password_auth')
if not last_auth or (time.time() - last_auth) > max_age_seconds:
return redirect(f'/reauth/?next={request.path}')
return view_func(request, *args, **kwargs)
return wrapper
return decorator
@require_fresh_auth(max_age_seconds=300)
def delete_account(request):
...
Binding a session strictly to IP address and user agent produces a steady stream of false positives. Phones move between Wi-Fi and mobile networks, carrier-grade NAT changes the outward-facing address, and browsers update themselves. The practical approach is soft binding. At login, record a coarse fingerprint (the IP network, not the exact address, and the browser family without its version number). When a later request does not match, raise the session's risk level instead of killing it outright. Run it in log-only mode for a week or two first, so you can set thresholds from real traffic. Remember that user-agent strings are sent by the client and can be copied. They filter out careless reuse of stolen cookies, but they do not prove identity.
| Signal change | Common benign cause | Suggested reaction |
|---|---|---|
| Exact IP address | Mobile networks, CGNAT, IPv6 privacy addresses | Log only |
| IP network (/24 IPv4, /48 IPv6) | Switching Wi-Fi to cellular, VPN on/off | Step-up auth for sensitive actions |
| Country or ASN | Travel, corporate VPN egress | Step-up auth, notify the user |
| Entirely different OS and browser | Rare for one cookie to move legitimately | Revoke the session, require full login |
| Impossible travel between requests | GeoIP errors, VPN | Revoke or step-up; alert security |
With the database session backend, the user id is stored inside the encoded session data, so "log this user out everywhere" cannot be done with a cheap query. Keep a small index table filled from the auth signals, and delete through SESSION_ENGINE so the same code works for the database, cache and cached_db backends:
# accounts/models.py
class UserSession(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
related_name="tracked_sessions")
session_key = models.CharField(max_length=40, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
# accounts/signals.py
from importlib import import_module
from django.contrib.auth.signals import user_logged_in
@receiver(user_logged_in)
def track_login(sender, request, user, **kwargs):
if request.session.session_key is None:
request.session.save()
UserSession.objects.update_or_create(
session_key=request.session.session_key, defaults={"user": user})
def revoke_sessions(user, keep_session_key=None) -> int:
engine = import_module(settings.SESSION_ENGINE)
tracked = user.tracked_sessions.exclude(session_key=keep_session_key)
for row in tracked:
engine.SessionStore(session_key=row.session_key).delete()
return tracked.delete()[0]
A "sign out all other devices" button is then revoke_sessions(request.user, keep_session_key=request.session.session_key). This does not work with the signed_cookies backend, because there the session lives only in the browser and nothing exists on the server to delete. Avoid that backend in any app where you may need to revoke sessions. Password changes give you a second lever for free. AuthenticationMiddleware compares each session's stored auth hash with the current password hash, so changing the password invalidates every other session. Call update_session_auth_hash(request, user) to keep the current one. API tokens and JWTs are not covered by that check and must be revoked separately.
If your app gets breached or your users' credentials get harvested elsewhere and tried against you, you want to know early.
*@yourcompany.com appearing in new leaks)# Periodic check (cron job) — alert on new breaches of your domain
import requests
def check_pwned_domain(domain: str, api_key: str):
headers = {"hibp-api-key": api_key}
r = requests.get(
f"https://haveibeenpwned.com/api/v3/breaches?domain={domain}",
headers=headers,
timeout=10,
)
r.raise_for_status()
breaches = r.json()
# Compare against last known list, alert on new ones
return breaches
For HIBP's password check on user login (free, anonymous via k-anonymity), the implementation in tutorial 2 covers it.
A genuine question many companies face. The wrong answer: try to buy it back. (You can't verify the data was deleted; you fund the criminal economy; you may be committing crimes yourself.)
The right responses:
Investigate the source. Where did this come from? Recent breach? Compromise of a partner? Insider exfil? You need to know to prevent recurrence.
Notify the regulator and affected users. Under GDPR, a personal data breach must be reported to the supervisory authority within 72 hours of becoming aware of it, and affected users must be told without undue delay when the breach is likely to put them at high risk. Honest, clear communication: what happened, what's at risk, what users should do.
Force password resets and revoke sessions. Even if passwords were hashed, assume worst-case. Invalidate any session/token issued before the breach.
Engage legal counsel. Notification requirements vary by jurisdiction and data type. Get advice early.
Engage law enforcement. Dutch NCSC, Politie Cybercrime Team, or sector regulators. They may have visibility into the seller or related cases.
Engage a DFIR firm if scope is unclear. Specialized firms (KPMG, Deloitte, Northwave NL, Fox-IT, smaller boutiques) handle investigation and stakeholder communication.
Post-incident review. What detection should have caught this earlier? What controls could have prevented it? Update your IR playbook.
Understanding the criminal economy is what makes these threats concrete rather than abstract. Stolen data has market prices that reflect its usefulness: a working set of credentials, a session token that bypasses login, a full identity profile each command different amounts depending on freshness and the access they unlock. This is a real economy with sellers, buyers, intermediaries, and reputation systems, and your application's data is potential inventory in it. Seeing the threat through this economic lens clarifies why attackers want what they want and how they monetize it, which in turn tells you what to protect most fiercely — the data and access that are worth the most to them.
Because people reuse passwords, a credential leaked from one breached site is tried automatically against thousands of others — credential stuffing. Attackers run these at enormous scale with automated tools and lists of billions of leaked pairs, and even a tiny success rate yields many compromised accounts. This is why a breach elsewhere becomes your problem, and why defenses like rate limiting, anomaly detection on login patterns, and offering strong multi-factor or passwordless authentication matter so much. The reused password is the thread that connects every breach into a single ongoing risk, and credential stuffing is the machine that exploits it across the whole web continuously.
No single control stops stuffing, because each request looks like an ordinary login with a real username. Layer three things. Reject credentials that are already known to be exposed. Slow down the automation. Make a correct password insufficient on its own.
The Pwned Passwords range API checks a password without ever sending it, or even its full hash. You send the first five hex characters of its SHA-1 hash. The service returns every suffix that shares that prefix, and you look for yours locally. The Add-Padding header also hides the true size of the response. As a password validator, the check runs at registration, password change and reset:
# accounts/validators.py -- add "accounts.validators.PwnedPasswordValidator"
# to AUTH_PASSWORD_VALIDATORS
import hashlib
import requests
from django.core.exceptions import ValidationError
class PwnedPasswordValidator:
def validate(self, password, user=None):
digest = hashlib.sha1(password.encode(), usedforsecurity=False).hexdigest().upper()
try:
r = requests.get(f"https://api.pwnedpasswords.com/range/{digest[:5]}",
headers={"Add-Padding": "true"}, timeout=2)
r.raise_for_status()
except requests.RequestException:
return # fail open; log and alert on this in production
for line in r.text.splitlines():
suffix, _sep, count = line.partition(":")
if suffix == digest[5:] and int(count) > 0: # padding rows have count 0
raise ValidationError("This password appears in a known data breach.",
code="password_pwned")
def get_help_text(self):
return "Your password must not appear in known data breaches."
Failing open keeps registrations working during an outage of the API, and CommonPasswordValidator remains as an offline safety net. Passwords can also turn up in a breach after they were set. Run the same check after a successful login, and if it matches, send the user to a password change with an explanation rather than blocking them.
Stuffing traffic is usually spread over many addresses, each making only a few attempts, so a per-IP limit on its own achieves little:
| Key | Catches | Misses |
|---|---|---|
| Per client IP | Single-host brute force | Distributed stuffing |
| Per target account | Guessing against one user | One attempt per account across many accounts |
| Global failure rate | A stuffing campaign as a whole | Very slow campaigns |
Put a coarse per-IP limit at the reverse proxy (nginx limit_req). Keep per-account and global counters in the application, using a cache that all workers share, fed from the user_login_failed signal. django-axes is an established package for this. Prefer delays and challenges to hard lockouts, which let anyone lock a victim out of their account. Show the same message whether or not the account exists. A sustained rise in global failures, especially with many usernames that do not exist, is the classic sign of a stuffing run and should page someone.
Passkeys (WebAuthn) are bound to your origin and resist phishing, so steer users towards them. TOTP (for example via django-otp) defeats pure stuffing but can be relayed by a real-time phishing proxy. SMS codes are exposed to SIM swaps and are best kept as a fallback. MFA protects the login, not the session that follows, so it complements the session controls above rather than replacing them. Protect the MFA recovery flow as strongly as the login itself.
The longer a compromise goes unnoticed, the more damage it does, so early detection is a defense in itself. Watch for the signals: logins from unusual locations or devices, sudden bursts of failed attempts, access patterns that do not fit a user's history, data being exported in volume. Logging these events and alerting on anomalies turns a silent, months-long breach into one you catch in hours. The data trade depends on stolen access staying valid and undetected; making your environment one where unusual activity is quickly noticed and acted on directly undercuts the value of whatever an attacker manages to steal from you.
Even when your own systems are sound, your users are exposed by breaches at other services through reused passwords, and a responsible application helps protect them. Checking new passwords against known-breached password lists, prompting for stronger authentication on sensitive actions, and notifying users of suspicious account activity all reduce the chance that someone else's breach becomes a compromise on your platform. This protective posture toward your users — recognizing that their security is partly shaped by events outside your walls — is part of operating responsibly in an ecosystem where stolen credentials circulate constantly and inevitably get tried against you.
Assume some data will leave one day, and make it worth as little as possible to whoever has it. Minimise first. Do not collect fields you do not need. Store a birth year rather than a full date of birth if that is enough. Enforce retention with a scheduled deletion command, and remember that backups and logs need retention limits too. Encrypt the few columns whose leak would hurt most, with keys held outside the database, so that a SQL injection or a stolen dump yields only ciphertext. The cryptography package's MultiFernet supports key rotation, and an HMAC "blind index" column keeps exact-match lookups possible. Tokenise where a provider can hold the sensitive value for you.
| Data | Store as | What a database dump yields |
|---|---|---|
| Passwords | Slow salted hash (PBKDF2 default, or Argon2) | Hashes that are expensive to crack |
| Card numbers | Stripe customer / PaymentMethod IDs only | IDs useless without your secret key |
| Government IDs, bank details | Field-level encryption + blind index | Ciphertext, if the keys were kept elsewhere |
| API keys you issue | Hash plus a short visible prefix | Nothing that works as a credential |
Alongside the organisational steps above, an on-call engineer needs a concrete sequence. Capture evidence first: take snapshots and copy the logs to write-once storage, because revoking and rotating destroys state. Then invalidate:
Session.objects.all().delete() logs everyone out. clearsessions only removes expired sessions and is not a revocation tool.SECRET_KEY: if it may have leaked, rotate it. That invalidates sessions, password-reset tokens and signed values. Do not add the old key to SECRET_KEY_FALLBACKS.set_unusable_password(), because Django's reset form skips users with unusable passwords.On notification, GDPR sets two separate duties. Article 33 requires telling the supervisory authority within 72 hours of becoming aware, unless the breach is unlikely to result in a risk to individuals. Article 34 requires telling affected people without undue delay when the risk to them is high. The user message should say what happened, which of their data was involved and which was not, what you have already done, and what they should do (change reused passwords, enable MFA, expect phishing). Leave out login links, and tell users to type your address themselves, because attackers imitate breach notices quickly.
Your users' data, once stolen, doesn't disappear. It circulates. It's sold. It's used in attacks against you and against unrelated services for years. The damage extends well beyond the initial breach.
That's why prevention matters so much. Detection-and-response is valuable, but the gap between "data stolen" and "first downstream attack" is often days, sometimes hours. By the time you're responding, the data is already in markets.
Prevention starts with the basics from tutorials 1-2. Detection (tutorial 10) catches what prevention misses. Both layers, both serious, both ongoing.
Found a mistake in this tutorial? Let us know and we will fix it.