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

// 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

  1. 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/login
    
    HTTP/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 required

    The body asks for a second factor and the header hands out a session cookie in the same breath. Note that and carry on.

  2. 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/account
    
    Account page for alice (mfa=false)
    status 200

    This is the defect. The account page asked whether a session exists, not whether it completed the second factor, and mfa=false says so in the response itself. In a browser this route is unreachable, which is why the bug survives manual testing.

  3. 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-strict
    
    Second factor not presented
    status 401

    Same 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.

  4. 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; done
    
    post 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 -d folds 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.

  5. 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; done
    
    post 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.

  6. 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; done
    
    guess 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.004230s

    Twenty 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

Sign: Two factor authentication passes every manual test and the post-login URL is open to anyone holding a first-factor cookie.Cause: The check lives in the page flow rather than in the session. A browser never issues the second request on its own, so only a client that skips the form finds it. Step 2 is that request.
Sign: A team says replay is impossible because the code changes every 30 seconds.Cause: It changes every step, and within the step it does not. Step 4 accepts one code three times. With a window of one, the same value stays acceptable across a 90 second band.
Sign: The attempt limit on the password form is tested, the one on the code form is not.Cause: They are separate counters in most implementations. Step 6 runs twenty guesses against the code form of a server whose password form does lock, and nothing stops them.
Sign: Testing against a public login instead of a local target.Cause: Steps 4 and 6 are a replay and a burst. Run them against your own process. The server in Prerequisites exists so that the interesting part of this page never touches someone else's service.

What to check next

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.

intermediate9 minpublished updated Maks Verny