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
- Node 22 or later. Save the file below as
reset-demo.js. One account, no dependencies, a fixed variant of every route. - curl 8 or later, any build. See the curl manual.
- No mail leaves the machine. The server prints the link to stdout, as most test environments do.
- A free port. Check with
netstat -ano | grep 8968first.
// 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
- 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] - 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] - 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
401responses from the sign-in form, and a team that closes it there often leaves it open here. - Step 4.
Read the link the server would have mailed.
grep MAIL mail.log | tail -1MAIL to=alice@example.test subject="Reset your password" link=http://127.0.0.1:8968/reset?token=31efd8167484e950e856360062d0845332 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. - 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 -> 200That is the happy path, and where most reset test cases stop.
- 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 -> 200The second
200is the finding. The token was not consumed, so that mail takes the account again at any time, including after the owner resets it back. - 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/accountSigned 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.
- 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.
/resetnever reads the issue time, so its tokens have no lifetime. On your own application, wait the window it documents. - 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/accountPassword 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 8968and pass it toStop-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
What to check next
- How to test login functionality: the byte-count leak in step 3, on the form where it is usually found first.
- How to test change password flow: the same takeover through a live session instead of a mailed token.
- How to test email verification link: single use, expiry and guessability for the other token this server mails.
- How to check if session expires after logout: the server side of step 7.
- How to test account lockout after failed logins: whether the reset form is rate limited, since it mails on request.
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.
Related on this site
intermediate9 minpublished updated Maks Verny