Security Advanced

The Dark Side of Web Apps — Stolen Sessions, Credential Markets, Data Trade

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.

KC Ramo DjangoZen Team May 10, 2026 17 min read 506 views

Why "what happens after" matters

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.

What gets stolen from web apps — and the resale value

Different data types follow different markets:

Credentials

  • Username + password combos — sold in bulk as "combolists." €5-€100 per million records.
  • Username + password + email — slightly more valuable. €50-€500 per million.
  • Session tokens / JWTs / cookies — even more valuable when fresh, because they bypass MFA. €1-€50 per session, days to weeks of validity.
  • OAuth tokens — long-lived, often broad-scoped. €10-€500 per token depending on what they access.

Personally Identifiable Information (PII)

  • Email + name — bulk, low value. Used for phishing target lists. €5-€50 per million.
  • Email + name + phone — better for vishing/smishing. €50-€500 per million.
  • Full identity dossiers — name, DOB, address, government ID number, mother's maiden name. Used for synthetic identity fraud. €10-€100 per record.

Financial data

  • Credit card numbers (CVV included) — "CC fullz." €5-€100 per card depending on freshness, country, and card type.
  • Bank account credentials — €100-€2,000+ per account depending on balance and country.
  • Stripe/PayPal API keys — sold per key, often via automated marketplaces. €100-€5,000.

Business data

  • Customer lists from B2B SaaS — used for competitive intelligence or targeted phishing of those customers. Variable, sometimes high (€1k-€100k+ for valuable segments).
  • Source code repositories — used to find more vulnerabilities, or sold to competitors. Variable.
  • Internal documents, contracts, communications — leveraged for blackmail or sold to interested parties.

The marketplaces

Where stolen data gets sold:

Dark web markets (Tor-hosted)

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.

  • Major markets get taken down periodically; new ones replace them
  • Specialize in different goods (drugs, credentials, financial, fraud kits)
  • Have escrow, reputation systems, customer support

Telegram channels

Increasingly dominant for credential and access trade. Lower bar to participation than Tor. Channels with thousands of subscribers buying/selling/discussing in real time.

  • Cheaper to operate than dark web markets
  • Faster transactions
  • Less protected — law enforcement can join channels
  • Often used for "fresh" stolen data before it commoditizes

Specialized forums

Long-running boards (some 15+ years old) where higher-tier criminals meet. Examples: Exploit.in, XSS.is, Verified.

  • More vetted membership
  • Higher quality data, higher prices
  • Used by IABs to advertise high-value accesses

Automated marketplaces

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.

What attackers do with web app session cookies specifically

This is worth a dedicated section because it's a common and underestimated attack vector.

How session theft works

  1. Attacker compromises a user's browser or device — via malware, MITM on insecure network, or by stealing cookies from a backup
  2. Exports session cookies for valuable sites (your SaaS, banking, cloud consoles)
  3. Imports them into a fresh browser
  4. Now logged in without needing the password OR the MFA code

Tools that automate this: "Cookie Cloner," various stealer malware (RedLine, Vidar, Raccoon), browser extensions sold for the purpose.

Why MFA doesn't help

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.

Defenses

  • Short session lifetimes — 1-24 hours, not days/weeks. Forces fresh authentication.
  • Re-auth for sensitive actions — even mid-session, require password re-entry for high-stakes operations.
  • Session binding to client characteristics — IP, user agent, TLS fingerprint. Reject sessions when these change.
  • Session activity anomaly detection — alert when a session is used from a new location in an unusual pattern.
  • Token rotation — issue new session tokens periodically within a session, invalidate old ones.
  • Logout = full revocation — server-side session invalidation, not just client-side cookie clearing.
  • Step-up auth on sensitive endpoints — /admin/*, password change, payment method change all require fresh authentication.

Django specifics

# 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):
    ...

Session binding without locking out real users

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 changeCommon benign causeSuggested reaction
Exact IP addressMobile networks, CGNAT, IPv6 privacy addressesLog only
IP network (/24 IPv4, /48 IPv6)Switching Wi-Fi to cellular, VPN on/offStep-up auth for sensitive actions
Country or ASNTravel, corporate VPN egressStep-up auth, notify the user
Entirely different OS and browserRare for one cookie to move legitimatelyRevoke the session, require full login
Impossible travel between requestsGeoIP errors, VPNRevoke or step-up; alert security

Revoking sessions in Django

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.

Threat intelligence — monitoring for your own data

If your app gets breached or your users' credentials get harvested elsewhere and tried against you, you want to know early.

Services to subscribe to

  • HaveIBeenPwned — free, well-known. API for checking domains and email addresses.
  • DeHashed, Constella, IntelX — paid services with broader coverage including non-public dumps.
  • SpyCloud, Recorded Future, Flashpoint — enterprise threat intel platforms.
  • Industry ISACs — sector-specific information sharing groups (FS-ISAC for finance, H-ISAC for healthcare, etc.).

What to monitor for

  • Your domain in credential dumps (e.g., *@yourcompany.com appearing in new leaks)
  • Your brand or product name in dark forum mentions (you're being targeted, planned attacks)
  • IP addresses or infrastructure indicators that match your own
  • Source code references — your unique strings, internal endpoint paths

Integrating monitoring into your stack

# 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.

What to do when you find your data on a dark web market

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:

  1. Investigate the source. Where did this come from? Recent breach? Compromise of a partner? Insider exfil? You need to know to prevent recurrence.

  2. 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.

  3. Force password resets and revoke sessions. Even if passwords were hashed, assume worst-case. Invalidate any session/token issued before the breach.

  4. Engage legal counsel. Notification requirements vary by jurisdiction and data type. Get advice early.

  5. Engage law enforcement. Dutch NCSC, Politie Cybercrime Team, or sector regulators. They may have visibility into the seller or related cases.

  6. Engage a DFIR firm if scope is unclear. Specialized firms (KPMG, Deloitte, Northwave NL, Fox-IT, smaller boutiques) handle investigation and stakeholder communication.

  7. Post-incident review. What detection should have caught this earlier? What controls could have prevented it? Update your IR playbook.

The economics of the underground

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.

Credential stuffing at scale

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.

Credential-stuffing defences in Django

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.

Breached-password checks with k-anonymity

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.

Rate limiting on more than one axis

Stuffing traffic is usually spread over many addresses, each making only a few attempts, so a per-IP limit on its own achieves little:

KeyCatchesMisses
Per client IPSingle-host brute forceDistributed stuffing
Per target accountGuessing against one userOne attempt per account across many accounts
Global failure rateA stuffing campaign as a wholeVery 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.

MFA as the backstop

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.

Detecting compromise early

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.

Protecting users after a breach elsewhere

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.

Limiting the value of what can be stolen

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.

DataStore asWhat a database dump yields
PasswordsSlow salted hash (PBKDF2 default, or Argon2)Hashes that are expensive to crack
Card numbersStripe customer / PaymentMethod IDs onlyIDs useless without your secret key
Government IDs, bank detailsField-level encryption + blind indexCiphertext, if the keys were kept elsewhere
API keys you issueHash plus a short visible prefixNothing that works as a credential

Breach response: the technical side

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:

  • Sessions: with the database backend, 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.
  • Passwords: force resets with a flag checked in middleware. Avoid set_unusable_password(), because Django's reset form skips users with unusable passwords.
  • Tokens and secrets: API tokens, OAuth grants, database credentials, and payment, email and cloud keys.

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.

Closing — the data-after-breach reality

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.