Brute-Force Protection: Rate Limiting, Lockout, CAPTCHA
AI generated
OWASP
0x00
Security · OWASP · Magento 2 · Brute Force
Brute-Force Protection: Rate Limiting, Lockout, CAPTCHA
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.

16 min read Rate Limiting · Account Lockout · Progressive Delay Magento_Security · CAPTCHA · MFA

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.

11. FAQ: Brute-Force Protection

1What is the difference between brute force and credential stuffing?
Brute force tests many passwords against a few accounts. Credential stuffing tests leaked credentials from other services against many accounts, usually once per account. Detected via number of usernames per IP.
2Why is account lockout alone risky?
Locking by username alone enables targeted denial-of-service against other people's accounts, since an attacker can deliberately enter wrong passwords without ever guessing the real one.
3How does progressive delay work?
Each failed attempt doubles the wait time up to a cap. Legitimate users barely notice anything, automated attacks become economically unattractive.
4Should rate limiting be IP-based only?
No. Pure IP limiting blocks users behind shared NAT and misses distributed botnet attacks. Combine IP-based and account-based limiting.
5When should a CAPTCHA be shown?
Only after failed attempts or an elevated risk score, not on every login. CAPTCHA on every login measurably hurts conversion.
6How do I configure Magento's built-in login protection?
Stores > Configuration > Advanced > Admin > Security for admins, Stores > Configuration > Customers > Login Options for customers. Both with their own thresholds.
7What does the Magento_Security module do?
Baseline lockout logic for the admin area, password lifetime, password complexity, and two-factor authentication, controlled via system.xml and config.xml.
8How do I detect credential-stuffing attacks in monitoring?
Through the number of distinct usernames per IP address in a short time window. A sudden spike across many accounts is a strong signal.
9Does MFA replace rate limiting?
No. MFA prevents success, not the attempts themselves. Without rate limiting, the second factor remains an independent attack target.
10Which metrics should I monitor for login attacks?
Consider failed logins per time window, locked accounts, distinct usernames per IP, and CAPTCHA trigger rate together.