Learn 2FA Authentication - Rate Limiting & Replay Protection
Episode 10 of 23

Learn 2FA Authentication - Rate Limiting & Replay Protection

This episode hardens the verification endpoints: rate limiting with express-rate-limit to narrow the 6-digit brute-force, and replay protection by tracking the last time-step so a TOTP code can't be reused within the same 30-second window.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

A 6-digit TOTP code has a search space of only one million possibilities. Without extra protection, an attacker could guess the code by trying thousands of times per second. Episode 10 closes two complementary gaps: rate limiting to slow down attempts, and replay protection so an already-valid code can't be reused.

The two are different but support each other. Rate limiting attacks the attacker side; replay protection attacks the protocol weakness. You'll install both on the MFA verification endpoints and understand how they work behind the scenes.

Rate Limiting on the MFA Endpoint

express-rate-limit

express-rate-limit@7 is installed as middleware on the most sensitive route: code verification. A common limit is 5 attempts per minute per IP or per account:

JSRate limit the MFA verification endpoint
const { rateLimit } = require('express-rate-limit');
 
const mfaLimiter = rateLimit({
  windowMs: 60 * 1000,
  limit: 5,
  standardHeaders: 'draft-7',
  legacyHeaders: false
});
 
app.post('/login/mfa', mfaLimiter, mfaHandler);
app.post('/account/enable-2fa/confirm', mfaLimiter, confirmHandler);

The mfaLimiter middleware counts requests per IP within a 60-second window. After 5 attempts, further requests are rejected with a 429 status until the window rolls over. The standardHeaders option sends RateLimit headers that clients can read.

Choosing the Scope: IP or Account

An IP-based rate limit stops a single attacker, but can trap users behind a shared NAT. An account-based limit is fairer but can be bypassed by an attacker with many accounts. The best solution combines both: a strict per-account limit and a looser per-IP limit as a safety net.

Limits on the Enrollment Flow

Don't forget to protect the enrollment confirmation endpoint with the same limit. The 6-digit secret typed during activation is just as fragile as a login code, and an attacker with access to the pending secret could guess the code to hijack the enrollment.

As a rule of thumb, every route processing a 6-digit TOTP should use a limit as strict as /login/mfa — this consistency is easier to test than a long list of exceptions.

Replay Protection

The Problem: A Code Can Be Used Twice

The tolerance window otplib allows (for example window 1) keeps a code valid for 90 seconds. Within that range, the same code could be reused if the server doesn't track usage — for instance, an attacker who intercepts one code uses it before the legitimate owner. RFC 6238 refers to this control as replay mitigation.

Tracking the Last Time-Step

The solution is simple: store the last successfully used time-step, and reject codes coming from a step that's already been recorded:

JSTime-step based replay protection
const currentStep = Math.floor(Date.now() / 1000 / 30);
 
if (user.lastUsedStep === currentStep) {
  return res.status(400).json({ error: 'Kode sudah dipakai' });
}
 
const valid = authenticator.check(req.body.token, decryptSecret(user.totp_secret_encrypted));
if (!valid) {
  return res.status(400).json({ error: 'Kode tidak valid' });
}
 
await users.update({
  where: { id: user.id },
  data: { lastUsedStep: currentStep }
});

The lastUsedStep column stores the value of the last successful step. If the next attempt arrives with the same step, the server rejects it before computing the code — cheap and effective. The update runs only after the code validates.

The Flow of a Valid Code

Visualizing three steps in one window:

Tolerance window and replay
step-1   step        step+1
 15 s    30 s        30 s       <- range of valid codes
   x  (already used)  x  (used now)
        last used = step        <- the same step is rejected

A code from the same step is rejected even if it's still within the tolerance window — this is what distinguishes time tolerance from permission to reuse.

Common Mistakes

Rate Limit Too Loose

A limit below 10 attempts per minute for 6 digits is often considered enough, but actually still gives an attacker tens of thousands of attempts per hour across many IPs. Set a strict limit at the application layer, then tighten further at the edge layer such as a CDN or firewall.

Updating lastUsedStep Before Verification

Updating lastUsedStep before checking the code would block a legitimate user who typed a wrong code in the same step. The update must always run after a successful verification — the order in the example code above is not a coincidence.

Conclusion

Episode 10 closed two gaps on the verification endpoints: rate limiting slows down the 6-digit brute-force with express-rate-limit, and replay protection rejects already-used codes by recording lastUsedStep.

The key takeaways:

  • Limit MFA verification attempts to around 5 per minute.
  • Combine per-account and per-IP rate limits.
  • Also protect the enrollment confirmation endpoint.
  • Store the last time-step to reject code reuse.
  • Check lastUsedStep before computing the code.
  • Update lastUsedStep only after the code validates.

In the next episode, episode 11, we will cover disabling, resetting, and rotating the secret — turning off 2FA with re-authentication, replacing the secret when a device is suspected of leaking, and the user status lifecycle from disabled to pending to enabled.

Learn 2FA Authentication - Rate Limiting & Replay Protection | Learn 2FA Authentication