Combining rate limiting, lockout, and CAPTCHA the right way
Login forms without a well thought out brute-force defense are among the most exploited entry points in Magento stores. This article shows how rate limiting, progressive delay, account lockout, and risk-based CAPTCHA work together, without locking out legitimate customers or hurting conversion, including Magento's built-in protections and concrete configuration examples.
Table of Contents
- 1. Why login forms are a favorite attack target
- 2. Account lockout and the denial-of-service risk
- 3. Progressive delay: exponential backoff per attempt
- 4. Combining IP-based and account-based rate limiting
- 5. CAPTCHA placement: risk-based instead of on every login
- 6. Configuring Magento's built-in login protection
- 7. Credential stuffing vs. brute force: the difference matters
- 8. Monitoring and alerting on repeated failures
- 9. Multi-factor authentication as a complementary layer
- 10. Summary
- 11. FAQ
1. Why login forms are a favorite attack target
Login endpoints are among the most heavily automated attack surfaces of a Magento store, because they are publicly reachable and a single hit grants direct access to an account, an order history, or, in the worst case, the admin panel. Tools like Hydra, Medusa, or custom scripts against the REST API automatically test thousands of combinations per minute, often from rotating botnets designed to bypass classic IP blocking.
The difference between a naive and a robust brute-force defense directly decides whether an attack gets noticed within seconds or stays undetected for weeks. A compromised customer account in Magento checkout enables fraudulent orders through stored payment data, while a compromised admin account enables full takeover of the store. The following sections cover the individual building blocks of a layered defense concept, from lockout through rate limiting to multi-factor authentication.
2. Account lockout and the denial-of-service risk
The most obvious reaction to repeated failed logins is locking the affected account after a fixed number of failures. This pattern has an under-appreciated downside though: if the lockout depends solely on the username or email address, an attacker can deliberately disable other people's accounts by repeatedly entering a wrong password for a known email address. The result is a denial-of-service attack against legitimate users, without the attacker ever having to guess a password.
In an online store this risk becomes very tangible: a competitor or malicious user could specifically lock customer accounts right before a purchase completes, or permanently block every known admin username of a store. The countermeasure is to never treat lockout in isolation, but always in the context of IP address, device, and time window, and to replace or at least complement hard lockouts with progressive delay before an account gets fully locked.
3. Progressive delay: exponential backoff per attempt
Instead of hard-locking an account after the third failed attempt, progressive delay makes every further attempt wait for a growing span of time. A typical pattern is exponential backoff: the wait time doubles with each failed attempt, starting at one second, capped at a sensible upper bound of a few minutes. For a legitimate user who mistypes twice, the delay is barely noticeable. For an automated attacker who wants to test thousands of combinations per minute, the attack becomes economically unattractive.
It is important to enforce the delay server-side and never compute it in the client, since an attacker trivially bypasses client-side wait times. Counting failed attempts should happen in a fast store like Redis, with a time-to-live (TTL) that automatically resets the counter after a defined quiet period. That keeps the mechanism self-healing, without manual resets by support staff.
<?php
declare(strict_types=1);
namespace Mironsoft\LoginProtection\Model;
/**
* Calculates and enforces progressive delay for failed login attempts.
*/
final class ProgressiveDelayGuard
{
private const BASE_DELAY_SECONDS = 1;
private const MAX_DELAY_SECONDS = 300;
private const RESET_AFTER_SECONDS = 900;
public function __construct(
private readonly \Mironsoft\LoginProtection\Model\AttemptStoreInterface $attemptStore
) {
}
/**
* Returns the number of seconds the caller must wait before retrying.
*
* @param string $identifier Account identifier or IP address
* @return int Seconds to wait, 0 if the next attempt is allowed immediately
*/
public function getRequiredWaitSeconds(string $identifier): int
{
$failures = $this->attemptStore->getFailureCount($identifier, self::RESET_AFTER_SECONDS);
if ($failures === 0) {
return 0;
}
// Exponential backoff: delay doubles per failure, capped at MAX_DELAY_SECONDS
$delay = self::BASE_DELAY_SECONDS * (2 ** min($failures, 12));
return (int) min($delay, self::MAX_DELAY_SECONDS);
}
/**
* Records a failed login attempt for the given identifier.
*
* @param string $identifier Account identifier or IP address
* @return void
*/
public function recordFailure(string $identifier): void
{
$this->attemptStore->increment($identifier, self::RESET_AFTER_SECONDS);
}
/**
* Clears the failure counter after a successful login.
*
* @param string $identifier Account identifier or IP address
* @return void
*/
public function recordSuccess(string $identifier): void
{
$this->attemptStore->reset($identifier);
}
}
4. Combining IP-based and account-based rate limiting
Rate limiting based purely on IP address overlooks two common situations: several legitimate users share the same public IP behind a corporate NAT or VPN, so a single wrong login can lock out multiple coworkers. Conversely, an attacker with a botnet spreads their attempts across hundreds of IP addresses, so each individual IP stays below the detection threshold while the target account still receives thousands of attempts overall. Pure IP limiting therefore solves neither the attacker's distribution problem nor the collateral-damage problem for users.
The resilient solution combines both signals with distinct, deliberately chosen thresholds: a coarse limit per IP address as the first line of defense against individually aggressive sources, and a finer, account-based limit with progressive delay as a second layer against distributed attacks on a single target. At the infrastructure level, the coarse IP limit can already be enforced in the web server before the request reaches the application, saving resources because obviously excessive requests never even reach the PHP process.
# nginx.conf: coarse IP-based rate limiting as first line of defense
# in front of the Magento login and REST auth endpoints
limit_req_zone $binary_remote_addr zone=login_zone:10m rate=5r/m;
limit_req_status 429;
server {
listen 443 ssl http2;
server_name shop.example.com;
location = /customer/account/loginPost {
limit_req zone=login_zone burst=3 nodelay;
limit_req_log_level warn;
try_files $uri /index.php$is_args$args;
}
location = /rest/V1/integration/customer/token {
limit_req zone=login_zone burst=3 nodelay;
try_files $uri /index.php$is_args$args;
}
location = /admin {
# Admin login gets a stricter zone with lower burst tolerance
limit_req zone=login_zone burst=1 nodelay;
try_files $uri /index.php$is_args$args;
}
}
5. CAPTCHA placement: risk-based instead of on every login
Showing a CAPTCHA on every single login attempt looks secure at first glance, but it measurably hurts conversion, because every extra click and every extra loading time pushes users toward abandoning the flow, especially on mobile devices. The more effective strategy is risk-based CAPTCHA: it does not appear by default, only once certain signals point to elevated risk, such as a certain number of failed attempts for an account or an IP address, an unusually new device, or a suspicious request velocity.
Invisible variants like reCAPTCHA v3 or hCaptcha compute a risk score in the background without actively bothering the user, and only trigger a visible challenge once the score is low. For Magento stores this means: use CAPTCHA as the last escalation step after rate limiting and progressive delay, not as the first hurdle before every single login. This ordering keeps friction at zero for the vast majority of legitimate users and only activates protection for the small share of suspicious requests.
// Trigger a CAPTCHA challenge only after suspicious activity,
// never on the first login attempt of a session.
const FAILURE_THRESHOLD_FOR_CAPTCHA = 3;
/**
* Decides whether a CAPTCHA challenge must be rendered for the next
* login attempt, based on recent failure count and risk signals.
* @param {number} recentFailures Failed attempts for this account/IP pair
* @param {number} riskScore Risk score from an invisible check (0 = risky, 1 = trusted)
* @returns {boolean} True if a visible CAPTCHA challenge is required
*/
function requiresCaptchaChallenge(recentFailures, riskScore) {
if (recentFailures >= FAILURE_THRESHOLD_FOR_CAPTCHA) {
return true;
}
// Invisible risk assessment (e.g. reCAPTCHA v3) below trust threshold
return riskScore < 0.5;
}
async function handleLoginSubmit(formData, recentFailures) {
const riskScore = await fetchInvisibleRiskScore(formData);
if (requiresCaptchaChallenge(recentFailures, riskScore)) {
return renderVisibleChallenge();
}
return submitLogin(formData);
}
6. Configuring Magento's built-in login protection
Magento ships with a configurable baseline defense through the Magento_Security module, controlled from the admin panel under Stores > Configuration > Advanced > Admin > Security. There you can set the maximum number of failed login attempts before a lockout, the lockout duration in minutes, as well as password lifetime and password complexity. A comparable, separate configuration exists for customer login under Stores > Configuration > Customers > Customer Configuration > Login Options, which allows its own thresholds independent of the admin area.
Module developers can extend these values via system.xml or add their own fields, while config.xml supplies sensible defaults for fresh installs. The actual lockout logic lives in Magento\Security\Model\AdminSessionsManager and Magento\Security\Model\SecurityChecker, which are evaluated on every login attempt. Something operators of multiple websites should watch for: admin lockout values apply globally to the adminhtml area, while customer lockout settings are configurable per website, which should be checked explicitly in multi-site setups.
<!-- app/code/Mironsoft/LoginProtection/etc/adminhtml/system.xml -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd">
<system>
<section id="admin" translate="label" type="text" sortOrder="10"
showInDefault="1" showInWebsite="0" showInStore="0">
<group id="security" translate="label" type="text" sortOrder="1"
showInDefault="1" showInWebsite="0" showInStore="0">
<!-- Native Magento_Security fields, referenced for context -->
<field id="lockout_failures" translate="label" type="text"
sortOrder="30" showInDefault="1">
<label>Maximum Login Failures to Lockout Account</label>
</field>
<field id="lockout_threshold" translate="label" type="text"
sortOrder="40" showInDefault="1">
<label>Lockout Time (minutes)</label>
</field>
</group>
</section>
</system>
</config>
<!-- app/code/Mironsoft/LoginProtection/etc/config.xml -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
<default>
<admin>
<security>
<!-- Lock the admin account after 5 failed attempts -->
<lockout_failures>5</lockout_failures>
<!-- Lockout duration in minutes -->
<lockout_threshold>30</lockout_threshold>
</security>
</admin>
<customer>
<login_options>
<max_login_failures>6</max_login_failures>
<lockout_time>15</lockout_time>
</login_options>
</customer>
</default>
</config>
7. Credential stuffing vs. brute force: the difference matters
Classic brute force tests many passwords against one or a few known accounts, often using word lists or systematic character combinations. Credential stuffing works the other way around: the attacker uses email-password pairs leaked from previous breaches of other services and tests each combination exactly once, but against very many different accounts. Because each account usually only sees a single attempt, account-based rate limiting is nearly useless here, since the failure counter for each account individually stays at zero or one.
Credential stuffing becomes detectable through other signals: an unusually high number of distinct usernames tried from the same IP address or the same device fingerprint in a short time window. In addition, checking new passwords against known breach databases such as the Pwned Passwords API from Have I Been Pwned during registration and password change helps reject compromised credentials outright, before they can even be used for an attack.
<?php
declare(strict_types=1);
namespace Mironsoft\LoginProtection\Model;
/**
* Detects credential stuffing by tracking distinct usernames attempted
* from the same IP address within a short time window using Redis.
*/
final class CredentialStuffingDetector
{
private const WINDOW_SECONDS = 300;
private const DISTINCT_USERNAME_THRESHOLD = 10;
public function __construct(
private readonly \Redis $redis
) {
}
/**
* Records a login attempt and returns whether the IP looks like
* a credential stuffing source rather than a single-account attack.
*
* @param string $ipAddress Client IP address
* @param string $username Attempted username or email
* @return bool True if this IP has tried too many distinct accounts
*/
public function trackAndEvaluate(string $ipAddress, string $username): bool
{
$key = 'stuffing:' . hash('sha256', $ipAddress);
$this->redis->sAdd($key, hash('sha256', strtolower($username)));
$this->redis->expire($key, self::WINDOW_SECONDS);
return $this->redis->sCard($key) >= self::DISTINCT_USERNAME_THRESHOLD;
}
}
8. Monitoring and alerting on repeated failures
Rate limiting and lockout prevent successful attacks, but without monitoring an ongoing attack remains invisible to the team until damage has already occurred. Every failed login should be logged in a structured way, with timestamp, IP address, targeted username, and the risk assessment result, ideally centralized in a SIEM system or a log pipeline like ELK or Grafana Loki instead of scattered across local Magento log files.
Magento's admin_user table already stores failures_num and lock_expires, which can be evaluated by a cron job and escalated to Slack, PagerDuty, or email once a threshold is exceeded. For the customer area, a dedicated alert on unusual patterns pays off: a sudden spike in failed logins spread across many accounts points more strongly toward credential stuffing than toward a targeted brute-force attack on a single account, and should be handled differently, for example by temporarily tightening global rate limits instead of locking individual accounts.
9. Multi-factor authentication as a complementary layer
Since Magento 2.4, Magento_TwoFactorAuth is active by default for the admin area and supports TOTP apps like Google Authenticator as well as U2F/WebAuthn security keys. Multi-factor authentication (MFA) does not stop an attacker from making login attempts, it stops an attack from succeeding, even when the correct password was guessed or came from a data breach. That is why MFA does not replace rate limiting: without rate limiting, an attacker can still test passwords indefinitely and attack the second factor as an independent target, for example through SIM swapping against SMS-based codes.
For Magento stores the combination is what matters: MFA mandatory for all admin accounts, WebAuthn preferred over SMS for its phishing resistance, and optional MFA offered to customers as an added benefit, especially with stored payment data. Rate limiting, progressive delay, and CAPTCHA remain necessary even with MFA enabled, because they already reduce the attack surface in front of the second factor and limit server load from automated attacks.
| Protection aspect | Insecure / naive | Recommended approach | Reason |
|---|---|---|---|
| Account lockout | Instant lock after 3 failed attempts per username | Progressive delay with IP context | Prevents denial-of-service against other people's accounts |
| Rate-limiting scope | IP-based only | IP and account signals combined | Detects both distributed and targeted attacks |
| CAPTCHA placement | On every login attempt | Risk-based after failed attempts | No conversion friction for legitimate users |
| Password validation | Format rules only at registration | Checked against breach databases (Pwned Passwords) | Prevents use of already leaked credentials |
| Admin access | Password only | Password plus MFA (TOTP/WebAuthn) | Stops takeover even with the correct password |
Mironsoft
Login security, rate limiting, and security audits for Magento stores
Ready to lock down your login forms against brute force?
We analyze your Magento store for lockout weaknesses, configure rate limiting at the infrastructure and application level, and set up risk-based CAPTCHA and MFA, without hurting conversion.
Security audit
Review of lockout, rate-limiting, and MFA configuration for DoS risks
Rate-limiting setup
nginx- and Redis-based limits combined with progressive delay
MFA rollout
WebAuthn and TOTP for admin accounts, optional MFA for customers
10. Summary
A resilient brute-force defense is never a single measure, but several layers coordinated with each other. Account lockout alone carries the risk that attackers can deliberately lock out legitimate users, so progressive delay with IP context should form the first line of defense. Rate limiting must combine IP-based and account-based signals, because neither distributed botnet attacks nor targeted attacks on a single account are reliably detected by a single dimension alone. CAPTCHA belongs at the end of the escalation chain, not at the start of every login, to avoid conversion losses.
Magento's Magento_Security module provides usable baseline settings for admin and customer login, which should still be adjusted based on store size and risk profile. Credential stuffing requires different detection signals than classic brute force, in particular the number of distinct usernames per source. Monitoring and alerting make ongoing attacks visible before damage occurs, and multi-factor authentication complements without replacing rate limiting. Together these building blocks form a defense-in-depth concept that covers both automated mass attacks and targeted attacks against individual high-value accounts.
Brute-Force Protection: Rate Limiting, Lockout, CAPTCHA - The Essentials at a Glance
Account lockout
Never lock by username alone. Progressive delay with IP context prevents denial-of-service against other people's accounts.
Rate limiting
Combine IP-based and account-based limits, coarse at the web server level, fine-grained inside the application.
CAPTCHA
Use risk-based after failed attempts, not on every login. Invisible variants like reCAPTCHA v3 reduce friction.
MFA & monitoring
MFA mandatory for admin accounts, complementary to rate limiting. Alert on failures_num and credential-stuffing patterns.