Cyber incidents hitting casino operators and gambling platforms jumped 400% between early 2025 and early 2026, according to threat intelligence firm Continent 8’s 2026 iGaming cybersecurity forecast. A large share of that spike traces back to one attack pattern: credential stuffing, the practice of firing usernames and passwords leaked in unrelated breaches at a completely different login form until a few of them land.
The scale is bigger than most operators expect. Imperva’s research on gaming industry threats puts the average at roughly 9,000 account takeover attempts a day across the sector, with brute-force and credential-stuffing techniques behind about 75% of that volume. Bots do almost all of the work. They don’t need to guess anything: they replay real username-password pairs stolen from some other company’s breach, betting that a chunk of your players reused the same combination.
Gaming and iGaming platforms sit high on the target list for a simple reason. A single compromised login can carry a stored payment method, a real-money wallet balance, loyalty points, or a fully KYC-verified identity, any of which resells well on its own. This tutorial builds a working, layered defense against that attack, step by step, using tools you can run on a laptop today and promote to production once you’ve tested them. Expect to spend around 70 minutes working through all 12 steps, most of it writing and testing small pieces of middleware rather than reading theory.
Two forces are pushing this up every operator’s priority list at once. Threat actors are increasingly using AI to automate the reconnaissance and testing side of these attacks, which shortens the time between a fresh breach dump appearing somewhere and it getting tested against your login form. At the same time, regulators are tightening expectations. European frameworks like NIS2 and the Cyber Resilience Act are pushing licensed operators toward continuous monitoring rather than a once-a-year audit, and licensing bodies increasingly want to see a fraud control working, not just paperwork confirming it exists. Building the defense described here gets ahead of both pressures at once, and it works the same whether your platform runs real-money games or free-to-play accounts with tradeable items.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
Credential Stuffing vs. Brute Force vs. Password Spraying
These three attacks get lumped together constantly, but they behave differently at the network level, which matters because each one needs a slightly different defense. Credential stuffing uses real, previously breached username-password pairs, so the attacker already has the right answer for a fraction of your users. Brute force throws large numbers of guesses at one account. Password spraying flips that around, trying one or two common passwords against thousands of accounts to stay under per-account lockout thresholds. A fourth pattern worth tracking alongside them is session hijacking, where the attacker skips the login form entirely and steals a live session token.
| Attack Type | Password Source | Typical Pattern | Main Detection Signal | Primary Defense |
|---|---|---|---|---|
| Credential stuffing | Real pairs leaked from other breaches | One correct attempt across many accounts, many IPs | Login pairs matching known-breach data | Breached-password screening, device and velocity checks |
| Brute force | Generated or guessed passwords | Hundreds to millions of attempts on one account | High failure volume on a single username | Rate limiting, account lockout |
| Password spraying | A short list of common passwords | One to three attempts across thousands of accounts | Low, slow failures spread across many usernames | Per-account thresholds, not just per-IP |
| Session hijacking | Stolen session tokens or cookies | No login attempt at all | Sudden device or location change mid-session | Session binding, step-up re-authentication |
| MFA fatigue (push bombing) | A password already compromised by one of the methods above | Repeated MFA push prompts sent until the user approves one by mistake | A burst of MFA prompts with no matching login intent from the account owner | Number-matching MFA instead of a single tap-to-approve push |
Credential stuffing is the one this tutorial focuses on, but every defense built here also cuts into the other three, since they all funnel through the same login endpoint.
The reason bots dominate this attack rather than humans doing it manually comes down to simple economics. A commodity residential-proxy subscription costs only a few dollars a month and rotates a fresh IP on every request, and off-the-shelf credential-stuffing toolkits automate the entire login-and-check cycle against a target site with no custom code required. That combination lets one attacker test millions of stolen pairs against your login form for the price of a cheap subscription, which is exactly why the defenses below focus on raising the cost and slowing the throughput of automation rather than trying to block every individual attempt outright.
Prerequisites: Tools, Accounts, and Versions
Gather these before starting Step 1. None of them cost anything at the scale used in this tutorial.
- Node.js 22 LTS or newer, with npm 10.x (bundled with Node 22)
- Docker and Docker Compose, used to run Redis locally
- Redis 7 or newer (the official image, or the open-source Valkey fork if you prefer)
- A free Cloudflare account for the Turnstile bot-challenge widget
- Outbound HTTPS access to api.pwnedpasswords.com (no API key required for the range search used here)
- Working knowledge of Express.js middleware
- A staging environment that is separate from production, needed for Step 11’s load test
- curl, Postman, or any REST client for manual endpoint testing
By the end you’ll have a small, complete project called login-defense-kit: an Express server, a set of middleware files for rate limiting, breached-password checks, and risk scoring, plus a Docker Compose file for Redis. Every step below adds one file or one function to that project, so nothing here is throwaway sample code.
Step 1: Scaffold the Project and Install Dependencies
Start with a clean Node project and pull in the four packages this stack leans on: Express for the HTTP layer, ioredis as the Redis client, and express-rate-limit with rate-limit-redis to share rate-limit counters across every app instance instead of trapping them in one process’s memory.
mkdir login-defense-kit && cd login-defense-kit
npm init -y
npm install express ioredis express-rate-limit rate-limit-redis dotenv
npm pkg set type="module"
Create server.js with a minimal skeleton to confirm everything boots before adding defense logic.
// server.js
import express from 'express';
import 'dotenv/config';
const app = express();
app.use(express.json());
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.listen(process.env.PORT || 3000, () => {
console.log('login-defense-kit listening on port 3000');
});
Run node server.js and confirm curl localhost:3000/health returns {"status":"ok"} before moving on.
Step 2: Stand Up Redis as Your Shared Attempt Ledger
Every defense in this tutorial depends on counters and flags that every app instance can read and write, which rules out plain in-memory storage the moment you run more than one server process. Redis fills that role, and Docker Compose keeps the setup reproducible across your laptop, staging, and CI.
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis-data:/data
command: redis-server --save 60 1
volumes:
redis-data:
Bring it up with docker compose up -d, then confirm it answers with docker compose exec redis redis-cli ping, which should print PONG. Add REDIS_URL=redis://localhost:6379 to a .env file in the project root.
Step 3: Build a Sliding-Window Rate Limiter
Use two separate limiters rather than one. A per-IP limiter catches a single source hammering many accounts, while a per-account limiter catches a botnet spreading attempts across thousands of proxy IPs at one target account. Neither alone covers both cases.
// middleware/rateLimiters.js
import rateLimit from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis';
import Redis from 'ioredis';
export const redisClient = new Redis(process.env.REDIS_URL);
export const perIpLoginLimiter = rateLimit({
windowMs: 10 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args) => redisClient.call(...args),
prefix: 'rl:ip:',
}),
keyGenerator: (req) => req.ip,
});
export const perAccountLoginLimiter = rateLimit({
windowMs: 10 * 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args) => redisClient.call(...args),
prefix: 'rl:acct:',
}),
keyGenerator: (req) => String(req.body?.username || 'unknown').toLowerCase(),
});
Wire both into the login route: app.post('/api/login', perIpLoginLimiter, perAccountLoginLimiter, loginHandler). The account-level limit of 5 attempts per 10 minutes is deliberately tight, since a real player who mistypes a password a couple of times is common, while a stuffing script fires dozens of times a minute.
Step 4: Add Progressive Delays and Exponential Backoff
A hard cutoff at attempt six is easy for automated tools to work around by rotating IPs before hitting the limit. A rising delay on every failed attempt is harder to route around, because it slows the attacker down even while they’re still under the rate-limit ceiling.
// middleware/progressiveDelay.js
export async function withProgressiveDelay(redisClient, key) {
const failures = await redisClient.incr(`fail:${key}`);
await redisClient.expire(`fail:${key}`, 900);
const delayMs = Math.min(2 ** failures * 250, 8000);
await new Promise((resolve) => setTimeout(resolve, delayMs));
return failures;
}
Call this after a failed password check, keyed by the same composite of IP and account used in Step 3. By the fifth failure the delay caps at 8 seconds, long enough to gut the throughput of a script trying thousands of pairs per minute without meaningfully affecting someone who just forgot their password.
Step 5: Screen Passwords Against Have I Been Pwned
Rate limiting slows an attack down. It does nothing to fix the underlying problem: a player reusing a password that’s already sitting in a breach dump somewhere. The Have I Been Pwned Pwned Passwords API lets you check a password against a corpus of breached credentials without ever sending the password itself over the network, using a k-anonymity model. You hash the password, send only the first five characters of that hash, and get back every known suffix that starts with that prefix, then compare locally.
// middleware/pwnedCheck.js
import { createHash } from 'node:crypto';
export async function isPasswordPwned(password) {
const sha1 = createHash('sha1').update(password).digest('hex').toUpperCase();
const prefix = sha1.slice(0, 5);
const suffix = sha1.slice(5);
const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
headers: { 'Add-Padding': 'true' },
});
const body = await res.text();
return body.split('\r\n').some((line) => line.startsWith(suffix));
}
The Add-Padding header asks the API to pad its response to a fixed size, which stops an eavesdropper from guessing how many matches came back just by looking at response length. Run this check at signup and at every password change, and reject matches with a message like “this password has appeared in a data breach, choose another” rather than a vague error. It’s also worth rerunning against your existing user base on a quarterly schedule, since new breach corpora land continuously and a password that was clean last year might not be now.
Step 6: Trigger a Bot Challenge After Repeated Failures
Showing a CAPTCHA on every login attempt annoys every player to catch a small number of bots. A better trade-off is to render a challenge only once the risk signals from earlier steps say something is off, for instance after the second failed attempt in a window. Cloudflare Turnstile is a free, non-puzzle option that mostly runs invisibly and only prompts for interaction when its own signals are ambiguous.
// middleware/turnstile.js
export async function verifyTurnstile(token, remoteIp) {
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
secret: process.env.TURNSTILE_SECRET_KEY,
response: token,
remoteip: remoteIp,
}),
});
const data = await res.json();
return data.success === true;
}
On the client, render the widget only after a failed attempt comes back from your API with a challengeRequired: true flag, and require a valid token on the next submission before the login handler even checks the password. That keeps the widget off the page for the overwhelming majority of logins that never fail once.
Step 7: Lock Accounts Without Leaking Who Exists
A common mistake undoes a lot of the work above: returning “no account with that email” for unknown usernames but “incorrect password” for known ones. That single difference lets an attacker enumerate every valid account on your platform before ever trying a password. Fix it by returning an identical generic message either way, and by running a dummy password comparison even when the account doesn’t exist, so response timing doesn’t leak the same information a different way.
// middleware/safeVerify.js
import bcrypt from 'bcrypt';
// Generate this once with bcrypt.hash('placeholder', 12) and hardcode the
// result. It never needs to match a real password, it only exists so a
// missing account takes the same amount of time to reject as a wrong one.
const DUMMY_HASH = process.env.DUMMY_BCRYPT_HASH;
export async function safeVerify(user, password) {
if (!user) {
await bcrypt.compare(password, DUMMY_HASH);
return false;
}
return bcrypt.compare(password, user.passwordHash);
}
Lock the account key itself, not just the IP, after repeated failures, and notify the player by email or SMS when a lock triggers, since that message is often how a real user first learns their password showed up in a breach.
Step 8: Score Devices, Velocity, and Impossible Travel
Everything so far acts on the login attempt itself. This step adds context: is this the device and location this account normally logs in from? A login from a new device in a new country nine minutes after the last session closed somewhere else isn’t proof of an attack on its own, but combined with other signals it’s a strong indicator worth a step-up challenge.
// middleware/riskScore.js
export function scoreLoginRisk({
isNewDevice,
isNewCountry,
minutesSinceLastLogin,
kmSinceLastLogin,
isKnownProxyIp,
}) {
let score = 0;
if (isNewDevice) score += 25;
if (isNewCountry) score += 20;
if (isKnownProxyIp) score += 30;
const hours = Math.max(minutesSinceLastLogin / 60, 0.01);
const impliedSpeedKmh = kmSinceLastLogin / hours;
if (impliedSpeedKmh > 900) score += 40;
if (score >= 70) return 'block';
if (score >= 35) return 'step_up_mfa';
return 'allow';
}
The 900 km/h threshold approximates commercial flight speed, a common baseline for flagging travel that isn’t physically plausible between two logins. Tune the weights against your own traffic for a couple of weeks before enforcing the “block” outcome, and start by only logging the score to confirm it isn’t flagging your own frequent travelers or shared-office players.
Step 9: Push WAF and Edge Rules for Login Endpoints
Everything built so far runs inside your application, which means every request still reaches your servers before you decide anything. Pushing the cheapest decisions to the edge, in front of your app, cuts load and reacts faster. A rule on a CDN or WAF layer can block traffic that scores as automated before it ever touches your login handler.
(http.request.uri.path eq "/api/login"
and cf.bot_management.score lt 30
and not cf.bot_management.verified_bot)
That expression is written in Cloudflare’s rules engine syntax as an illustration of the pattern: match the login path specifically, check a bot score below a threshold, and exclude verified good bots like uptime monitors. The exact fields and thresholds available depend on your WAF vendor and plan tier, so treat this as a template to adapt rather than a drop-in rule.
Step 10: Stream Detection Events to Your SIEM
None of the signals above matter if they only ever appear in application logs nobody reads until after an incident. Emit a structured event for every login decision, not just the blocked ones, so you can correlate volume and patterns later.
// lib/logEvent.js
export function logLoginEvent({ outcome, riskScore, username, ip, reason }) {
console.log(JSON.stringify({
type: 'login_decision',
outcome, // allow | challenge | block
riskScore,
username,
ip,
reason,
ts: new Date().toISOString(),
}));
}
Point a log shipper at stdout and forward it into whatever SIEM your security team already runs. If you don’t have one yet, our Wazuh SIEM Setup With Docker walkthrough covers standing one up specifically to ingest and alert on structured application logs like these.
Step 11: Load-Test Your Defenses in Staging
Run this step only against infrastructure you own and control, on a staging environment with no real user data in it, never against production or any system you don’t have explicit authorization to test. The goal is to confirm your own thresholds trigger correctly, not to simulate an attack on anyone else.
// staging-load-test.js (run with: k6 run staging-load-test.js)
import http from 'k6/http';
import { sleep, check } from 'k6';
export const options = {
vus: 20,
duration: '30s',
};
export default function () {
const res = http.post(
'https://staging.example.com/api/login',
JSON.stringify({ username: 'loadtest-user', password: 'intentionally-wrong' }),
{ headers: { 'Content-Type': 'application/json' } },
);
check(res, {
'server responded with an expected status': (r) => [200, 401, 429].includes(r.status),
});
sleep(1);
}
After the run, confirm three things in your logs: the per-account limiter kicked in around attempt six, the progressive delay measurably slowed later requests, and no request from a different, legitimate test account got caught in the same lockout. If any of those don’t hold, tune the thresholds in Steps 3 and 4 before this ships anywhere near production.
Step 12: Write the Incident Response Runbook and Layer On MFA
Detection without a response plan just produces a dashboard. Write down, in advance, what happens when the block rate on /api/login spikes: who gets paged, what the threshold for an automatic IP or account freeze is versus what needs a human to look first, how you force a bulk password reset for a confirmed-affected group of accounts, and what you tell players whose accounts got locked defensively.
Then close the gap that none of the previous eleven steps fully close on their own: a stolen or reused password can still work if nothing else is required. Multi-factor authentication is the backstop. Microsoft’s research, cited in the Cloud Security Alliance’s credential-stuffing guidance, found that MFA would have stopped 99.9% of the account compromises the company studied. Apply it as a step-up challenge triggered by the risk score from Step 8 rather than forcing it on every login, and prioritize rolling it out to high-value accounts first: VIP or high-roller tiers, affiliate and admin back-office logins, and any account with stored payment methods.
Complete Working Project: How All 12 Pieces Fit Together
Every file created across the steps above belongs to one project, not twelve disconnected snippets. Laid out on disk, login-defense-kit looks like this once all 12 steps are done:
login-defense-kit/
├── docker-compose.yml
├── .env
├── package.json
├── server.js
├── lib/
│ └── logEvent.js
└── middleware/
├── rateLimiters.js
├── progressiveDelay.js
├── pwnedCheck.js
├── turnstile.js
├── safeVerify.js
└── riskScore.js
A single login request moves through this stack in a fixed order, and the order matters: cheap checks run first so expensive ones never execute against traffic that was going to get rejected anyway.
- The edge WAF rule from Step 9 evaluates the request first, before it reaches your server at all.
- The per-IP and per-account rate limiters from Step 3 run next, rejecting anything already over threshold.
- If a challenge was flagged on a previous attempt,
turnstile.jsfrom Step 6 verifies the token before the password is even checked. safeVerify.jsfrom Step 7 compares the password, using the dummy-hash path for unknown accounts.- A failed attempt runs through
progressiveDelay.jsfrom Step 4 before the response goes out. - A successful password match still passes through
riskScore.jsfrom Step 8 before the session is issued, since a correct password from a suspicious device still warrants a step-up MFA challenge. - Every outcome, allow, challenge, or block, gets written out through
logEvent.jsfrom Step 10 for the SIEM to pick up.
None of these pieces depends on a specific cloud provider or a paid vendor beyond Cloudflare’s free Turnstile tier, so the whole stack runs identically on a laptop, in staging, and in production, with only the Redis connection string and the Turnstile keys changing between environments. That portability is deliberate. A defense stack that only works on one team’s infrastructure tends to quietly stop getting maintained the first time that infrastructure changes.
Advanced Tips and Compliance Notes for Regulated Operators
The 12 steps above cover a solid baseline. A few refinements matter once the basic stack is live and you’re tuning it against real traffic instead of a staging test, especially as attackers adjust their own tooling in response to what you’ve shipped.
None of the five tips below replace the core stack. They sit on top of it, and they tend to matter most for larger operators with enough login volume to justify the extra tooling and process.
- Move to passkeys (WebAuthn) for the accounts where a compromise hurts most, since a passkey has no shared secret for a stuffing attack to replay in the first place.
- Feed known credential-stuffing infrastructure, such as data-center ASN ranges and disposable proxy pools, into the risk-scoring function from Step 8 instead of relying on a single static IP blocklist.
- Seed a small number of canary accounts with credentials that exist nowhere else. A login attempt against one is a near-certain signal your own user database has leaked, not a probability to weigh against other signals.
- Recheck existing users against updated breach corpora on a recurring schedule rather than only at signup, since new dumps surface continuously and old passwords don’t get safer with age.
- Route step-up MFA by risk score rather than applying it uniformly, so friction lands on the small slice of risky logins instead of every routine one.
- Run a quarterly tabletop exercise against the incident response runbook from Step 12, walking through a simulated mass-lockout event end to end, rather than waiting to test the plan for the first time during a real incident.
Larger operators running this at scale often add one more layer beyond what fits into a single tutorial: a dedicated fraud team that owns the risk-scoring weights the way a product team owns a roadmap, reviewing false-positive and false-negative rates weekly rather than leaving the thresholds from Step 8 untouched after launch.
Defense Layer Comparison at a Glance
| Defense Layer | What It Stops | Effort to Implement | False-Positive Risk | Where It Runs |
|---|---|---|---|---|
| Rate limiting (IP + account) | Bulk automated attempts | Low | Medium (shared IPs) | App middleware |
| Breached-password screening | Reused leaked passwords | Low | Low | Signup and password change |
| Bot challenge (Turnstile) | Scripted and headless bots | Low to medium | Low to medium | Login form, shown adaptively |
| Device and velocity scoring | Takeover attempts after a stolen pair works once | Medium | Medium | Post-authentication risk engine |
| WAF and edge bot management | High-volume distributed botnets | Medium (vendor setup) | Low | CDN or edge, before the app |
| MFA and passkeys | Any password-only compromise | Medium to high (rollout and UX) | Low | Account level, all users |
Compliance Notes for Licensed iGaming Operators
Regulators increasingly want to see how a fraud control works, not just confirmation that one exists. Licensed operators should keep the risk-scoring logic from Step 8 documented and versioned, log every threshold change with a reason, and be ready to show an auditor the runbook from Step 12 alongside real block-rate data. Our earlier coverage of 2026 changes to MGA and UKGC casino API audit requirements goes deeper on what testing labs now expect from operators during a license renewal, and it pairs directly with the account-security controls built in this tutorial.
Payment infrastructure adds another layer worth tracking separately. Any platform storing card data still has to satisfy PCI DSS regardless of what account-security controls sit in front of the login form, so treat the two as parallel obligations rather than assuming one covers the other. A player whose account gets taken over despite these defenses is also a support and trust problem, not only a security one, so the runbook from Step 12 should include a plain-language notice template ready to send before an incident happens, not drafted for the first time while it’s already unfolding.
Common Pitfalls When Defending Gaming and iGaming Logins
Most of the mistakes teams make here aren’t in the code, they’re in the assumptions behind it. Each of these has shown up often enough across gaming and iGaming login systems that it’s worth checking against your own setup before calling this stack finished.
- Blocking purely by IP address. This punishes players sharing a mobile carrier’s NAT or a campus network, and it misses botnets that rotate residential proxies on every single attempt.
- Forcing a hard CAPTCHA on every login. That kills conversion at the exact moment a player is trying to deposit and play. Trigger challenges adaptively, based on risk, not by default.
- Forgetting the native mobile app’s API path. Stuffing traffic often skips your web form entirely and hits the same backend endpoint your app uses, unprotected by anything that only watches browser traffic.
- Leaving password-reset and username-recovery endpoints unprotected. They leak the same account-existence signal as the login form and are rate-limited less often, which makes them an easy side door.
- Logging raw request bodies while debugging. Plaintext passwords end up in a log aggregator or SIEM index, turning a defense project into its own compliance problem.
- Treating this as a one-time deployment. Credential-stuffing tooling adapts within days of a new defense going live. Thresholds need a standing owner, not a launch date.
- Skipping the runbook. A detection system that pages nobody and blocks nothing automatically just produces a dashboard that sits unread until an audit asks for it.
Troubleshooting Guide
| Symptom | Likely Cause | Fix |
|---|---|---|
| connect ECONNREFUSED on startup | Redis container isn’t running, or the app is looking outside the Docker network | Run docker compose ps and confirm the service name matches REDIS_URL’s host |
| Real players locked out right after using a password manager | Autofill submits fast enough to trip burst thresholds meant for bots | Raise the short-window burst limit slightly, or exempt fast-but-correct submissions |
| Turnstile widget never renders in local dev | localhost isn’t in the site key’s allowed domain list | Add 127.0.0.1 and localhost in the Cloudflare Turnstile dashboard |
| HIBP range lookup returns HTTP 429 | Too many uncached requests from one server IP in a short window | Cache prefix responses briefly and add retry-with-backoff |
| High false-positive rate on VPN users | iGaming players often use VPNs for legitimate reasons, overlapping with proxy-abuse signals | Weight VPN signal lower and combine with device and velocity checks before blocking |
| Rate-limit counters reset after every deploy | Limiter is using in-memory storage instead of the Redis store | Confirm the store option points at the shared Redis instance |
| SIEM channel is too noisy to act on | Every allow, challenge, and block event logs at the same severity | Tier alerts: blocks page on-call, challenges go to a daily digest |
| Impossible-travel flags fire on same-city logins | Server clocks are out of sync across app instances | Sync NTP across every node computing the time delta |
| Bot challenge blocks the official mobile app’s in-app browser | Some in-app WebViews score as automation under bot-management heuristics | Allowlist verified app user-agent and token combinations server-side |
| WAF rule silently blocks your own load balancer health checks | The rule matches the login path too broadly, including internal probes | Scope the rule to external client traffic and exclude known health-check paths |
Frequently Asked Questions
What’s the real difference between credential stuffing and a brute-force attack?
Brute force guesses passwords for one account. Credential stuffing already has a correct username-password pair, stolen from a breach on a different, unrelated site, and simply tries it on yours, betting on password reuse.
Can rate limiting by itself stop credential stuffing?
No. Rate limiting slows an attack and forces it to spread across more IPs and accounts, but a stuffing attempt only needs one correct pair to succeed, so limiting attempt volume alone won’t catch it. Pair it with breached-password screening and device or velocity risk scoring.
Does checking passwords against Have I Been Pwned send user passwords to a third party?
No. The Pwned Passwords range API uses k-anonymity: your server sends only the first five characters of the password’s SHA-1 hash, gets back every breached hash suffix sharing that prefix, and checks for a match locally. The full password and full hash never leave your server.
Will adding a CAPTCHA hurt conversion on a real-money platform?
It can, if shown on every login. Showing it only after risk signals or repeated failures trigger it keeps friction off the vast majority of routine, successful logins while still challenging the traffic that matters.
How quickly do attackers adapt once these defenses go live?
Fast, often within days, which is why Step 12’s runbook and a standing owner for tuning thresholds matter as much as the initial build. Treat this as a maintained system, not a project with an end date.
Do we still need MFA if all 12 steps above are already running?
Yes. Everything before Step 12 makes a successful stuffing attempt harder and slower to pull off, but MFA is what stops a correct password from being enough on its own. Microsoft research cited by the Cloud Security Alliance puts MFA’s effectiveness against account compromise at 99.9%, which is why it belongs as the backstop, not an optional extra.
Does any of this apply to non-gambling game platforms, like Steam-style accounts or in-game marketplaces?
Yes, directly. Any platform with a login, a stored payment method, or tradeable in-game items or currency is a credential-stuffing target for the same reason a casino account is. The rate limiting, breached-password checks, and risk scoring here apply without modification.
What should licensed iGaming operators be ready to show a regulator about these controls?
Documentation of the risk-scoring logic, a change log for threshold adjustments, and real block-rate and incident data tied to the runbook from Step 12. Auditors increasingly want evidence a control works as described, not just confirmation it exists on paper.
How is credential stuffing different from the account-existence leaks caused by a data breach?
A data breach is the source, credential stuffing is what happens next. Once a breach at any company exposes usernames and passwords, those pairs get tested everywhere else the same person might have an account. That’s also why old, years-old breach dumps keep circulating: password reuse means a leak from years ago can still power an attack against a completely unrelated platform today.
What’s a reasonable first target for the account-level rate limit in Step 3?
Five failed attempts per account in a 10-minute window is a reasonable starting point for most login forms, tight enough to blunt automated attempts while leaving room for a real player who mistypes a password once or twice. Watch your own false-positive rate for a week or two after launch and adjust from there rather than treating any single number as fixed.
For more coverage of the threats defining this year, visit our cybersecurity hub.
Sources and further reading: OWASP’s Credential Stuffing Prevention Cheat Sheet, NIST SP 800-63B digital identity guidelines, and Redis documentation.





