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.

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.
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:
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.
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.
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.
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.
The solution is simple: store the last successfully used time-step, and reject codes coming from a step that's already been recorded:
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.
Visualizing three steps in one window:
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 rejectedA 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.
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 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.
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:
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.