Why is a correct password not enough?
A password proves only that someone knows it. When a member of the Riverside Bird Club uses the same password for the club, an online shop and a gaming forum, a leak at any one of those sites puts the club account at risk as well. Billions of email and password pairs from old leaks circulate in lists that anyone can download.
Attackers feed those lists to bots that try each pair on thousands of sites. This is called credential stuffing. It looks very different from guessing: each account gets one try with a password that really did belong to that person, often from a different address on the internet each time. The rate limit you set earlier, login_attempts_per_minute = 5, never trips, because no account sees more than one attempt.
Strong passwords do not help here either, because the strong password was leaked whole. The member's mistake was reuse. The site's defence is to ask for a second thing that never travelled with the password.
What counts as a second step?
Sign-in factors are usually grouped as something you know, such as a password; something you have, such as a phone or a security key; and something you are, such as a fingerprint checked by your phone. Two-step sign-in, often called two-factor authentication or 2FA, combines a password with one of the others.
The common methods, from weakest to strongest, are a code sent by email, a code sent by text message, a code from an authenticator app, and a passkey or hardware security key. All of them stop the plain credential stuffing in the lab, because a leaked list contains passwords and nothing else. They differ in how well they hold up when the attacker works harder.
The lab's site.conf has two settings for this. two_step can be off, optional or required, and two_step_method names the method. The lab blocks stuffing whenever a second step is required, whatever the method. Real attackers are less polite, and the next sections explain where each method falls short.
How do authenticator app codes work?
When a member turns on app codes, the site creates a random secret for them and shows it as a QR code. Their authenticator app saves the secret. From then on, both the app and the server combine the secret with the current time and work out the same six-digit number, which changes every 30 seconds. This scheme is called TOTP, short for time-based one-time password.
Nothing travels over the phone network, so a SIM swap cannot steal the code. The secret stays on the member's phone and on the server. In Node, the otplib package does the arithmetic; the club's /sign-in/code route calls authenticator.check(req.body.code, member.totp_secret).
Two details matter in your own code. The secret is sensitive, so encrypt it in the database and never send it back to the browser after setup. And a six-digit code has only a million possible values, so the code step needs its own rate limit, or a bot can work through them.
if (!member || !authenticator.check(req.body.code, member.totp_secret)) {
return res.status(401).send('That code did not work.');
}Why are text messages and email weaker?
A text message code depends on the phone network. In a SIM swap, an attacker persuades a mobile provider to move the member's number to a new SIM card, and the codes go to them. Email codes depend on the member's email account, which may share the same reused password the attacker already has.
Both are still much better than nothing, and some members have no smartphone, so offering them as a fallback is reasonable. The lab passes two_step_method = sms and mentions the SIM swap in its reason.
All code methods, app codes included, share one weakness: phishing. A fake page at a look-alike address can ask for the password and the code, then pass both to the real site within the 30 seconds. Tools that automate this exist. The code proves the member has their phone; it says nothing about which site they typed it into.
What makes passkeys different?
A passkey is a pair of keys created on the member's device for one site. The site stores only the public half. To sign in, the site sends a challenge, the device signs it with the private half after a fingerprint, face scan or device PIN, and the site checks the signature. There is no code to type and nothing reusable to steal from the server.
The browser tells the device which site is asking, and a passkey made for riversidebirds.example will not respond to riversidebirds-login.example. That is why passkeys resist phishing in a way codes cannot. Current versions of Chrome, Edge, Firefox and Safari support them. Most passkeys sync between a member's devices through their password manager or platform account; a passkey on a hardware security key stays on that key.
Building passkey sign-in from scratch is involved, and most small sites use a well-known library or their sign-in provider's support for it. Offering passkeys alongside app codes is a sensible goal for a club site.


Why required, and what about lost phones?
Optional two-step protects the members who switch it on, and in practice that is a small share of any site's members. The lab reports this when you set two_step = optional. For an admin account, or anyone who can change other members' details, make it required. For ordinary members, required is safest, and the trade-off is extra support work.
Phones get lost. When a member sets up two-step, give them a set of single-use recovery codes, about ten, to keep somewhere safe. Store them hashed, like passwords. Make the recovery process for members who lose everything slow and careful, because attackers use the support route to bypass the second step entirely.
Many sites offer a "remember this device" option that skips the code for a while. Treat that as a long-lived sign-in token: put it in an HttpOnly cookie, give it an expiry, and cancel it when the member changes their password.
What do AI tools get wrong about two-step sign-in?
Generated code often checks the second step only in the browser: the code screen is a page, and nothing on the server stops someone from skipping it and requesting /sightings directly. The server must refuse every signed-in page until the session records that both steps passed. The club's code keeps the half-signed-in member in pendingId and sets memberId only after the code is checked.
Other slips: no rate limit on the code route; a request field such as skip2fa=true left in from testing; recovery codes stored in plain text; the TOTP secret generated in front-end JavaScript; and no fresh session after the second step. Assistants also still describe SMS as the standard method. When reviewing, request a signed-in page with only the first step done and confirm it is refused.
Field notes
- A correct password alone can come from a leak, so a second step proves more.
- Authenticator apps make a fresh six-digit code every 30 seconds from a shared secret.
- Text and email codes help, but phone numbers can be stolen and messages intercepted.
- Passkeys are tied to the real site, so a fake login page cannot collect them.
Easy to get wrong
If the session counts as signed in before the code, the code screen is decoration. Anyone with the password can skip it.
Optional protection relies on every admin remembering to switch it on. One who forgets is the way in.
A six-digit code has a million values. Without a limit, a bot can try them all.
Questions people ask
Is two-step the same as two-factor?
In everyday use, yes. Strictly, two-factor means the two steps are different kinds of thing, such as a password and a phone. Two passwords would be two steps from a single factor.
Should I stop offering text message codes?
Keep them as a fallback for members without a smartphone if you need to, and encourage app codes or passkeys. Text codes still stop leaked password lists.
Do passkeys replace passwords completely?
They can. Many sites start by offering a passkey as the second step or as an alternative sign-in, and keep passwords for members who have not set one up.
Does the lab check my code step?
No. It reads the two_step setting and treats any required second step as stopping credential stuffing. A real review also checks that the server enforces the step and limits code attempts.