How to test change password flow

Sign in twice into two cookie jars, then change the password from one of them. curl -b jarP.txt -X POST http://127.0.0.1:8968/change-password -d 'new=phone-only-1' answers 200 without being asked for the current password, and the second jar keeps working afterwards. Both answers are defects.

Why check this

Run this on staging sign-off and after any change to the session store. Two questions decide the result, and neither one is about whether the new password works.

The first is what the endpoint trusts. If it changes the password on the strength of a cookie alone, a stolen session is no longer something an owner can end. It becomes a permanent takeover, because the thief sets the password. Asking for the current password keeps a stolen cookie temporary.

The second is what the change ends. A user changing a password usually suspects somebody else is in the account, and only dropping the other sessions removes that somebody. The change should keep the session that made it and end the rest. That is narrower than the rule in How to test password reset flow, where the user holds no session and all of them go.

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 and keep the process id.

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 twice from two clients, and confirm both sessions are live.

    curl -s -c jarP.txt -o /dev/null -w 'phone  login   -> %{http_code}\n' -X POST http://127.0.0.1:8968/login -d 'email=alice@example.test&password=correct-horse'
    curl -s -c jarL.txt -o /dev/null -w 'laptop login   -> %{http_code}\n' -X POST http://127.0.0.1:8968/login -d 'email=alice@example.test&password=correct-horse'
    curl -s -b jarP.txt -o /dev/null -w 'phone  account -> %{http_code}\n' http://127.0.0.1:8968/account
    curl -s -b jarL.txt -o /dev/null -w 'laptop account -> %{http_code}\n' http://127.0.0.1:8968/account
    
    phone  login   -> 200
    laptop login   -> 200
    phone  account -> 200
    laptop account -> 200

    Two separate jars, two separate session ids. The laptop jar stands in for the session you are trying to evict.

  2. Step 2.

    Change the password from the phone jar, and send no current password at all.

    curl -s -b jarP.txt -w '[status %{http_code}]\n' -X POST http://127.0.0.1:8968/change-password -d 'new=phone-only-1'
    
    Password changed for alice@example.test
    [status 200]

    The request carried a cookie and a new password, and no proof that the sender knows the account. Send this exact request against your own application: a form that renders a current-password field can still have a handler that ignores it.

  3. Step 3.

    Try both passwords at the sign-in endpoint.

    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=phone-only-1'
    
    old password -> 401
    new password -> 200

    The credential was replaced, and this is where most change-password test cases stop.

  4. Step 4.

    Replay the laptop session, which was created before the change.

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

    The old password is dead and the old session is alive. That asymmetry is the finding. Anyone holding a cookie from before the change keeps the account, while the user has been told the problem is solved.

  5. Step 5.

    Call the fixed route with a current password that is wrong.

    curl -s -b jarP.txt -w '[status %{http_code}]\n' -X POST http://127.0.0.1:8968/change-password-strict -d 'current=guessing&new=laptop-wins'
    
    Current password does not match
    [status 403]

    403 with a live session is the answer you want. The session is valid, the request is not.

  6. Step 6.

    Call it again with the current password, then read both jars.

    curl -s -b jarP.txt -w 'change  -> %{http_code}\n' -X POST http://127.0.0.1:8968/change-password-strict -d 'current=phone-only-1&new=phone-only-2'
    curl -s -b jarL.txt -o /dev/null -w 'laptop  -> %{http_code}\n' http://127.0.0.1:8968/account
    curl -s -b jarP.txt -o /dev/null -w 'phone   -> %{http_code}\n' http://127.0.0.1:8968/account
    
    Password changed for alice@example.test
    change  -> 200
    laptop  -> 401
    phone   -> 200

    401 on the laptop and 200 on the phone. Assert on both. A handler that drops every session logs out the person who changed the password, which reads as a bug and is usually fixed by removing the eviction altogether.

    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 with no current password in the request | The endpoint trusts the cookie | Require the current password and compare it in the handler, not in the form. | | 403 with a wrong current password | Re-authentication holds | Nothing. This is the result you want. | | 200 from the other jar after the change | The change ended no sessions | Drop every session for the account except the one that made the request. | | 401 from both jars after the change | The eviction is too wide | Keep the acting session, or issue it a new cookie in the same response. | | 200 on sign-in with the old password | The write did not commit | Check for a cached read of the user record after the update. | | The change succeeds while the session is expired | The route has no session check at all | Report against the route, not against the flow. |

Common mistakes

Sign: The test signs in, changes the password, signs in again, and passes.Cause: One client cannot see the session it is meant to evict. Step 1 opens a second jar that never touches the change, and step 4 replays it. Without that jar the question is not being asked.
Sign: The current-password field is on the form, so re-authentication is assumed.Cause: Step 2 posts only the new password and gets 200. The field is client-side decoration until a request without it is refused. Send the request the form would never send.
Sign: Every session is ended, and the report calls that the secure outcome.Cause: Step 6 keeps the acting session and drops the other. Logging out the user who just changed their password looks like a defect in the field, and the usual fix is to remove the eviction, which loses the property you wanted.

What to check next

FAQ

How to check if other sessions end after a password change?

Sign in twice into two cookie jars, change the password from the first, then request a protected route with the second, as steps 1, 2 and 4 do. 401 from the second jar is a pass. 200 means the change ended nothing, whatever the confirmation said.

How to check if the old password still works after a change?

Post the old password to the sign-in endpoint and read the status, as step 3 does. 401 is the pass. Do it from a client with no cookie jar, because a client that already holds a session never reaches the credential comparison.

Should changing a password require the current password?

Yes, and step 2 shows why. Without it a cookie is enough to set a new password, so a stolen session becomes permanent. With it, the owner can end the session and keep the account. Check the handler, not the form.

The application has only a web form. How do I send step 2?

Open DevTools, the Network tab, submit the form once, then right-click the request and copy it as curl. Delete the current-password field from the copied command and send it again. The response to that edited request is the finding.

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.

intermediate6 minpublished updated Maks Verny