How to test two factor authentication
Test the second factor from a client that does not follow the UI. Post the password, keep the cookie, then request the page that comes after login without ever sending a code. If that returns 200, the second factor is decoration. Then post one valid code twice and see whether both are accepted.
Why check this
Run this on staging sign-off for any release that touches login, and in regression after a change to session handling. A browser will not find these defects, because a browser always walks the steps in order. curl does not, and that is the whole point of the run.
The defects worth catching are structural. Four of them: the post-login URL is reachable without a code, the same code works twice, there is no attempt limit on the code form, and the first factor alone issues a session cookie that other endpoints honour. Steps 2, 4 and 6 reproduce the first three.
Prerequisites
- Node 22 or later. Save the server below as
auth-2fa-demo.js. totp.jsfrom How to test totp in the same directory. The server imports it to check codes.- curl 8 or later, for the cookie jar. Each route has a defective form and a strict form, so one run shows the bug and the fix side by side.
// auth-2fa-demo.js - local target for the 2FA and lockout checks. Node 22, no dependencies.
// Needs totp.js from "How to test totp" in the same directory.
const http = require('node:http');
const crypto = require('node:crypto');
const { totp, verify } = require('./totp.js');
const USERS = { alice: 'correct-horse', bob: 'hunter2' };
const SECRET = 'JBSWY3DPEHPK3PXP'; // alice's TOTP secret, the one the authenticator app holds
const MAX_FAILS = 5, LOCK_S = 15;
const sessions = new Map(); // sid -> { user, mfa }
const fails = new Map(); // key -> { n, until }
const used = new Map(); // user -> the TOTP counter already spent
const text = { 'content-type': 'text/plain' };
const now = () => Math.floor(Date.now() / 1000);
const sid = (req) => (/(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '') || [])[1] || null;
const form = (req) => new Promise((r) => { let s = ''; req.on('data', (d) => { s += d; }); req.on('end', () => r(new URLSearchParams(s))); });
const addr = (req) => req.headers['x-forwarded-for'] || '127.0.0.1'; // a test hook. Never trust this header from the internet.
const left = (key) => { const f = fails.get(key); return f && f.until > now() ? f.until - now() : 0; };
function countFail(key) {
const f = fails.get(key) ?? { n: 0, until: 0 };
f.n += 1;
if (f.n >= MAX_FAILS) f.until = now() + LOCK_S;
fails.set(key, f);
return f;
}
http.createServer(async (req, res) => {
const p = new URL(req.url, 'http://127.0.0.1').pathname;
const f = req.method === 'POST' ? await form(req) : new URLSearchParams();
const u = f.get('username') ?? '', pw = f.get('password') ?? '';
const key = p === '/login-ip' ? `ip:${addr(req)}` : `user:${u}`;
const send = (code, msg, extra = {}) => res.writeHead(code, { ...text, ...extra }).end(`${msg}\n`);
// First factor. /login is the defective one: the lock is tested only on the wrong-password path.
if ((p === '/login' || p === '/login-strict' || p === '/login-ip') && req.method === 'POST') {
if (p !== '/login' && left(key)) return send(403, `Account ${u} is locked. Try again in ${left(key)} s`, { 'retry-after': left(key) });
if (!Object.hasOwn(USERS, u)) return send(401, 'Invalid username or password');
if (USERS[u] === pw) {
fails.delete(key);
const id = crypto.randomBytes(16).toString('hex');
sessions.set(id, { user: u, mfa: false });
return send(200, 'Password accepted. Second factor required', { 'set-cookie': `sid=${id}; Path=/; HttpOnly; SameSite=Lax` });
}
const rec = countFail(key);
if (rec.until > now()) return send(403, `Account ${u} is locked. Try again in ${left(key)} s`, { 'retry-after': left(key) });
return send(401, `Invalid username or password (failure ${rec.n} of ${MAX_FAILS})`);
}
// Second factor. /2fa replays and never counts attempts. /2fa-strict spends the counter and counts.
if ((p === '/2fa' || p === '/2fa-strict') && req.method === 'POST') {
const s = sessions.get(sid(req));
if (!s) return send(401, 'No session. Post the password first');
const w = verify(SECRET, f.get('code') ?? '', { window: 1 });
if (p === '/2fa-strict') {
if (left(`2fa:${s.user}`)) return send(403, `Second factor locked. Try again in ${left(`2fa:${s.user}`)} s`);
if (w === null) { const r = countFail(`2fa:${s.user}`); return send(401, `Invalid code (failure ${r.n} of ${MAX_FAILS})`); }
const counter = Math.floor((now() + w * 30) / 30);
if (used.get(s.user) === counter) return send(401, `Code already used for counter ${counter}`);
used.set(s.user, counter);
fails.delete(`2fa:${s.user}`);
s.mfa = true;
return send(200, `Signed in as ${s.user}, counter ${counter} spent`);
}
if (w === null) return send(401, 'Invalid code');
s.mfa = true;
return send(200, `Signed in as ${s.user}, matched at offset ${w}`);
}
// /account is the defect under test: it asks for a session, not for a verified session.
if (p === '/account' || p === '/account-strict') {
const s = sessions.get(sid(req));
if (!s) return send(401, 'Not signed in');
if (p === '/account-strict' && !s.mfa) return send(401, 'Second factor not presented');
return send(200, `Account page for ${s.user} (mfa=${s.mfa})`);
}
if (p === '/state') return send(200, JSON.stringify([...fails].map(([k, v]) => `${k} n=${v.n} lockedFor=${Math.max(0, v.until - now())}s`)));
return send(404, 'Not found');
}).listen(8657, () => console.log('auth 2fa demo on http://127.0.0.1:8657'));
Start it, then find the Windows process id so you can stop that one process later.
node auth-2fa-demo.js &
netstat -ano | grep 8657
Steps
- Step 1.
Post the password on its own and keep the cookie. Read the headers as well as the body.
curl -s -i -c jar.txt --data 'username=alice&password=correct-horse' http://127.0.0.1:8657/loginHTTP/1.1 200 OK content-type: text/plain set-cookie: sid=bbb04941c2aaeda6b4a0067b8c66a5f5; Path=/; HttpOnly; SameSite=Lax Date: Fri, 11 Sep 2026 20:59:02 GMT Connection: keep-alive Keep-Alive: timeout=5 Transfer-Encoding: chunked Password accepted. Second factor requiredThe body asks for a second factor and the header hands out a session cookie in the same breath. Note that and carry on.
- Step 2.
Send that cookie to the page the UI would show after the code, skipping the code form entirely.
curl -s -b jar.txt -w 'status %{http_code}\n' http://127.0.0.1:8657/accountAccount page for alice (mfa=false) status 200This is the defect. The account page asked whether a session exists, not whether it completed the second factor, and
mfa=falsesays so in the response itself. In a browser this route is unreachable, which is why the bug survives manual testing. - Step 3.
Repeat against a route that checks the flag, so you know the cookie is not the problem.
curl -s -b jar.txt -w 'status %{http_code}\n' http://127.0.0.1:8657/account-strictSecond factor not presented status 401Same cookie, same second, opposite verdict. The difference is one condition in the handler, so the fix is per route and every protected route has to be listed and tested.
- Step 4.
Generate a valid code and post it three times to the code form.
CODE=$(node -e "const {totp}=require('./totp.js');process.stdout.write(totp('JBSWY3DPEHPK3PXP'))") for i in 1 2 3; do printf 'post %d: ' "$i"; curl -s -b jar.txt -w ' [%{http_code}]\n' --data "code=$CODE" http://127.0.0.1:8657/2fa | tr -d '\n'; echo; donepost 1: Signed in as alice, matched at offset 0 [200] post 2: Signed in as alice, matched at offset 0 [200] post 3: Signed in as alice, matched at offset 0 [200]Three acceptances of one code.
tr -dfolds the body and the status onto one line. Anyone who reads that code once, from a support chat or a shoulder, can use it until the step ends. - Step 5.
Post the same code to the route that records which counter it spent.
for i in 1 2; do printf 'post %d: ' "$i"; curl -s -b jar.txt -w ' [%{http_code}]\n' --data "code=$CODE" http://127.0.0.1:8657/2fa-strict | tr -d '\n'; echo; donepost 1: Signed in as alice, counter 59638678 spent [200] post 2: Code already used for counter 59638678 [401]The counter, not the six digits, is what has to be stored. A server that remembers the digits alone still accepts them after the next rotation brings them round again.
- Step 6.
Post twenty wrong codes in a row and watch for a limit or a delay.
for i in $(seq 1 20); do printf 'guess %02d: ' "$i"; curl -s -b jar.txt -w ' [%{http_code}] %{time_total}s\n' --data "code=$(printf '%06d' $i)" http://127.0.0.1:8657/2fa | tr -d '\n'; echo; doneguess 01: Invalid code [401] 0.006324s guess 02: Invalid code [401] 0.001710s guess 03: Invalid code [401] 0.002228s … guess 19: Invalid code [401] 0.001497s guess 20: Invalid code [401] 0.004230sTwenty attempts, no counter in the message, no lock, and a median around 1.6 ms. Stop the server when the run is done, by the process id from the Prerequisites block:
powershell -Command "Stop-Process -Id 31944 -Force".
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| 200 from the post-login URL with no code sent | The second factor is not enforced on that route | Raise it as critical. List every protected route and repeat. |
| mfa=false in a successful response | The server knows the factor is missing and serves anyway | Move the check into the session middleware, not the handler. |
| The same code accepted twice | Used counters are not recorded | Store the matched counter per user and refuse a repeat. |
| Twenty wrong codes, no lock and no delay | No attempt limit on the second step | Six digits is one million values. Add a limit. |
| A session cookie issued before the code | The first factor alone creates a usable session | Issue a pending token instead, and swap it after the code. |
| 401 from the code form with a correct code | Clock drift or the wrong stored secret | Check the counter against the TOTP procedure first. |
Common mistakes
What to check next
- How to test totp: generate the codes this page spends, and separate a clock fault from a logic fault.
- How to test account lockout after failed logins: the attempt limit that step 6 shows missing, tested properly on the same server.
- How to test login functionality: the first factor, which has to be right before any of this matters.
- How to test login with curl: the cookie jar mechanics every step here depends on.
- How to check if session expires after logout: whether the session the second factor created actually ends.
FAQ
How to check 2FA backup codes?
Treat each one as a single-use credential. Redeem one, then post it again and require a rejection, which is step 5 run against the backup code route. Then confirm a redeemed code is gone from the list the account page shows, and that generating a new set invalidates the old.
How to test 2FA without a phone?
Everything on this page runs without one. The code comes from the generator in the TOTP procedure, and the server checks it the same way it checks the app. Keep one real enrolment in the suite to confirm the enrolment URI parameters.
Can a browser find these defects?
Not these. A browser follows the flow the server offers, so the skipped route in step 2 and the replayed code in step 4 never get requested. Use a browser for the form, curl for the order of requests.
What should happen after the password but before the code?
A pending token that opens only the code route. Step 1 shows the opposite: a full session cookie handed over with the words "second factor required". Look for mfa=false or its equivalent in any response served to that cookie.
How many code attempts should be allowed?
Fewer than it takes to search the space. Six digits is one million values, and with a window of one, three of them are live at any moment. The lockout procedure measures where the limit bites.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate9 minpublished updated Maks Verny