How to test password reset flow

Request a reset, read the token the server prints, then spend it twice. curl -X POST http://127.0.0.1:8968/reset -d "token=$TOKEN&password=new-secret-1" answers 200 on both attempts when the token is not single use. Four separate defects live in this flow, and each one has a status code you can assert on.

Why check this

Run this on staging sign-off and after any change to the mail template, the token store or the session store. The flow is short, rarely exercised by hand, and it hands account access to whoever holds a string.

Four things fail here more often than the happy path. The token survives being used, so an old mail keeps working. The token has no expiry, so an archived mailbox holds a live key. The reset leaves sessions running, so an attacker who already has a cookie keeps the account. And the request form says whether an address is registered. The steps show all four.

Prerequisites

// reset-demo.js - local target for password reset, email verification and password change.
// Node 22, no dependencies. Every route has a defective variant and a fixed "-strict" one.
// No mail is sent. The link is written to stdout, the way most test environments do it.
const http = require('node:http');
const crypto = require('node:crypto');

const PORT = 8968;
const TTL = 2000; // ms, enforced only by the -strict routes
const users = new Map([['alice@example.test', { pw: 'correct-horse', verified: true }]]);
const sessions = new Map(); // sid -> email
const resets = new Map();   // token -> { email, issued }
const verifs = new Map();   // token -> { email, issued }
let counter = 1000;         // the weak token source

const rnd = () => crypto.randomBytes(16).toString('hex');
const weak = () => 'vrf-' + ++counter;
const t = { 'content-type': 'text/plain' };
const sid = (req) => (/(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '') || [])[1] || null;
const read = (req) => new Promise((r) => { let s = ''; req.on('data', (d) => { s += d; }); req.on('end', () => r(s)); });
const mail = (to, subject, link) => console.log(`MAIL to=${to} subject="${subject}" link=${link}`);

http.createServer(async (req, res) => {
  const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
  const p = url.pathname;
  const f = req.method === 'POST' ? new URLSearchParams(await read(req)) : url.searchParams;
  const now = Date.now();
  const send = (code, s) => res.writeHead(code, t).end(s + '\n');

  if (p === '/login') {
    const u = users.get(f.get('email'));
    if (!u || u.pw !== f.get('password')) return send(401, 'Sign in failed');
    const id = rnd();
    sessions.set(id, f.get('email'));
    return res.writeHead(200, { ...t, 'set-cookie': `sid=${id}; Path=/; HttpOnly; SameSite=Lax` }).end('Signed in\n');
  }

  if (p === '/account') {
    const who = sessions.get(sid(req));
    return who ? send(200, `Signed in as ${who}`) : send(401, 'Not signed in');
  }

  // The only route that asks whether the address was ever confirmed.
  if (p === '/post') {
    const who = sessions.get(sid(req));
    if (!who) return send(401, 'Not signed in');
    return users.get(who).verified ? send(200, 'Posted') : send(403, 'Verify your email first');
  }

  if (p === '/signup' || p === '/signup-strict') {
    const e = f.get('email');
    users.set(e, { pw: f.get('password'), verified: false });
    const tok = p === '/signup' ? weak() : rnd(); // the defect: a counter, not a secret
    verifs.set(tok, { email: e, issued: now });
    mail(e, 'Confirm your email', `http://127.0.0.1:${PORT}/verify?token=${tok}`);
    return send(201, `Account created for ${e}`);
  }

  if (p === '/verify' || p === '/verify-strict') {
    const tok = f.get('token');
    const r = verifs.get(tok);
    if (!r) return send(400, 'Unknown token');
    if (p === '/verify-strict') {
      if (now - r.issued > TTL) return send(400, 'Token expired');
      verifs.delete(tok); // the fix: single use
    }
    users.get(r.email).verified = true;
    return send(200, `Email verified for ${r.email}`);
  }

  if (p === '/forgot' || p === '/forgot-strict') {
    const e = f.get('email');
    const known = users.has(e);
    if (known) {
      const tok = rnd();
      resets.set(tok, { email: e, issued: now });
      mail(e, 'Reset your password', `http://127.0.0.1:${PORT}/reset?token=${tok}`);
    }
    if (p === '/forgot-strict') return send(200, 'If that address has an account, a reset link is on its way.');
    return send(200, known ? `Reset link sent to ${e}` : `No account for ${e}`); // the defect: the answer differs
  }

  if (p === '/reset' || p === '/reset-strict') {
    const tok = f.get('token');
    const r = resets.get(tok);
    if (!r) return send(400, 'Unknown token');
    if (p === '/reset-strict') {
      if (now - r.issued > TTL) return send(400, 'Token expired');
      resets.delete(tok); // single use
      for (const [id, who] of sessions) if (who === r.email) sessions.delete(id); // and every session goes
    }
    users.get(r.email).pw = f.get('password');
    return send(200, `Password changed for ${r.email}`);
  }

  if (p === '/change-password' || p === '/change-password-strict') {
    const e = sessions.get(sid(req));
    if (!e) return send(401, 'Not signed in');
    const u = users.get(e);
    if (p === '/change-password-strict') {
      if (u.pw !== f.get('current')) return send(403, 'Current password does not match');
      for (const [id, who] of sessions) if (who === e && id !== sid(req)) sessions.delete(id);
    }
    u.pw = f.get('new');
    return send(200, `Password changed for ${e}`);
  }

  return send(404, 'Not found');
}).listen(PORT, '127.0.0.1', () => console.log(`reset demo on http://127.0.0.1:${PORT}`));

Start it with stdout in a file, so the link is readable from the shell.

node reset-demo.js > mail.log 2>&1 &

On Windows the process id is the last column of netstat -ano | grep 8968. Stop that one id with PowerShell Stop-Process -Id <pid>, never every process named node.

Steps

  1. Step 1.

    Sign in first and keep the cookie jar. This is the session an attacker would already hold.

    curl -s -c jarA.txt -w '[status %{http_code}]\n' -X POST http://127.0.0.1:8968/login -d 'email=alice@example.test&password=correct-horse'
    
    Signed in
    [status 200]
  2. Step 2.

    Ask for a reset on an address that has an account. Print the byte count.

    curl -s -w '[status %{http_code}] [bytes %{size_download}]\n' -X POST http://127.0.0.1:8968/forgot -d 'email=alice@example.test'
    
    Reset link sent to alice@example.test
    [status 200] [bytes 38]
  3. Step 3.

    Ask again for an address with no account, and compare the answers.

    curl -s -w '[status %{http_code}] [bytes %{size_download}]\n' -X POST http://127.0.0.1:8968/forgot -d 'email=nobody@example.test'
    
    No account for nobody@example.test
    [status 200] [bytes 35]

    Same status, 38 bytes against 35. The form answers "does this address have an account here". How to test login functionality finds the same leak on two 401 responses from the sign-in form, and a team that closes it there often leaves it open here.

  4. Step 4.

    Read the link the server would have mailed.

    grep MAIL mail.log | tail -1
    
    MAIL to=alice@example.test subject="Reset your password" link=http://127.0.0.1:8968/reset?token=31efd8167484e950e856360062d08453

    32 hex characters from crypto.randomBytes. A token you can predict is a finding on its own, and How to test email verification link shows how to prove it.

  5. Step 5.

    Spend the token, then try both passwords.

    TOKEN=$(grep -o 'reset?token=[0-9a-f]*' mail.log | tail -1 | cut -d= -f2)
    curl -s -w '[status %{http_code}]\n' -X POST http://127.0.0.1:8968/reset -d "token=$TOKEN&password=new-secret-1"
    curl -s -o /dev/null -w 'old password -> %{http_code}\n' -X POST http://127.0.0.1:8968/login -d 'email=alice@example.test&password=correct-horse'
    curl -s -o /dev/null -w 'new password -> %{http_code}\n' -X POST http://127.0.0.1:8968/login -d 'email=alice@example.test&password=new-secret-1'
    
    Password changed for alice@example.test
    [status 200]
    old password -> 401
    new password -> 200

    That is the happy path, and where most reset test cases stop.

  6. Step 6.

    Send the same token a second time, with a different password.

    curl -s -w '[status %{http_code}]\n' -X POST http://127.0.0.1:8968/reset -d "token=$TOKEN&password=attacker-owns-this"
    curl -s -o /dev/null -w 'attacker password -> %{http_code}\n' -X POST http://127.0.0.1:8968/login -d 'email=alice@example.test&password=attacker-owns-this'
    
    Password changed for alice@example.test
    [status 200]
    attacker password -> 200

    The second 200 is the finding. The token was not consumed, so that mail takes the account again at any time, including after the owner resets it back.

  7. Step 7.

    Replay the session from step 1, created before the password changed.

    curl -s -b jarA.txt -w '[status %{http_code}]\n' http://127.0.0.1:8968/account
    
    Signed in as alice@example.test
    [status 200]

    A reset is what a user does when they believe somebody else is in the account. This one changed the password and left that somebody signed in.

  8. Step 8.

    Issue a fresh token, wait past the documented lifetime (2 seconds here), then send it to both routes.

    curl -s -o /dev/null -X POST http://127.0.0.1:8968/forgot -d 'email=alice@example.test'
    T2=$(grep -o 'reset?token=[0-9a-f]*' mail.log | tail -1 | cut -d= -f2)
    sleep 3
    curl -s -w '  <- /reset        [status %{http_code}]\n' -X POST http://127.0.0.1:8968/reset -d "token=$T2&password=late-1"
    curl -s -w '  <- /reset-strict [status %{http_code}]\n' -X POST http://127.0.0.1:8968/reset-strict -d "token=$T2&password=late-2"
    
    Password changed for alice@example.test
    <- /reset        [status 200]
    Token expired
    <- /reset-strict [status 400]

    One token, two routes, opposite verdicts. /reset never reads the issue time, so its tokens have no lifetime. On your own application, wait the window it documents.

  9. Step 9.

    Run the same assertions against the fixed route to see a pass.

    curl -s -o /dev/null -X POST http://127.0.0.1:8968/forgot-strict -d 'email=alice@example.test'
    T3=$(grep -o 'reset?token=[0-9a-f]*' mail.log | tail -1 | cut -d= -f2)
    curl -s -w '  <- first use  [status %{http_code}]\n' -X POST http://127.0.0.1:8968/reset-strict -d "token=$T3&password=fixed-1"
    curl -s -w '  <- second use [status %{http_code}]\n' -X POST http://127.0.0.1:8968/reset-strict -d "token=$T3&password=fixed-2"
    curl -s -b jarA.txt -w '  <- old session [status %{http_code}]\n' http://127.0.0.1:8968/account
    
    Password changed for alice@example.test
    <- first use  [status 200]
    Unknown token
    <- second use [status 400]
    Not signed in
    <- old session [status 401]

    Stop the server: take the id from netstat -ano | grep 8968 and pass it to Stop-Process -Id.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | 200 on the second use of one token | The token is not consumed when it is spent | Delete the record inside the same transaction that writes the password. | | 400 on the second use | Single use holds | Nothing. This is the result you want. | | 200 on a token older than the documented window | The expiry is written in the mail text and nowhere in the code | Compare the issue time on every use, not only at send time. | | 200 from the pre-reset session | The reset changed a credential and kept the sessions | Drop every session for that account during the reset. | | Two different bodies or byte counts on the request form | The form reports which addresses are registered | Return one answer for every address, identical in status, body and length. | | 500 on an unknown token | The failure path dereferences a missing record | Read the server log. An exception here often returns a stack trace to the client. |

Common mistakes

Sign: The reset test passes, and the tester never sent the token twice.Cause: A single use token and a reusable one behave identically on the first request. The difference appears only on the second, which step 6 makes with a different password so the effect is visible in the next sign-in.
Sign: The expiry is tested by reading the mail, which says the link is valid for one hour.Cause: That sentence is copy in a template. Step 8 sends one token to two routes on the same server and gets 200 and 400, because only one of them compares the issue time. The window is a property of the handler, not of the mail.
Sign: Everyone agrees the reset form must not leak, so nobody measures it.Cause: The two responses in steps 2 and 3 carry the same 200 status and differ by 3 bytes. An assertion on status passes. Assert on the byte count, because a client that never renders the text can still read the length.
Sign: The session check is done in the same browser that performed the reset.Cause: That browser was signed out by the reset page itself, so the result says nothing. Step 1 keeps a separate cookie jar that never touches the reset, which is the only client that can answer the question.

What to check next

FAQ

How to check if a password reset link expires?

Request a link, wait past the window the application documents, then use it. Step 8 uses a 2 second window and gets 200 from the route that ignores the issue time. Waiting tests the build you deployed, not the source.

How to check if a password reset link can be used twice?

Send the same token again with a different new password, as step 6 does, then sign in with that second password. A 400 on the replay is a pass. A 200 means an old mail keeps working, and the sign-in proves it.

How to write test cases for forgot password?

One case per row of the table above: known address, unknown address, valid token, replayed token, expired token, and a session older than the reset. Each asserts on a status and a body, because step 3 shows the status alone can pass while the body leaks.

How do I test this without a real mailbox?

Read the token where the application writes it: the log, a file transport, or a catch-all inbox. Step 4 reads stdout. Delivery is a separate concern, and the token is what this tests.

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