How to test email verification link
Sign up, read the link the server prints, and open it twice. curl 'http://127.0.0.1:8968/verify?token=vrf-1001' answers 200 on both attempts when the token is not consumed. Then sign in without verifying at all, and see how much of the product already works.
Why check this
Run this on staging sign-off, after a change to the mail template, and when signup moves behind a new proxy. Four facts settle it: the link works once, it stops working after its window, an unverified account cannot do what a verified one can, and the token cannot be guessed.
The third fact is the one that gets skipped, and it decides whether the other three matter. If every route already serves an unverified account, the link is decoration, and the defect is not in the link. Step 3 signs in without opening the mail and reads two routes. One of them lets the account through.
Prerequisites
- Node 22 or later. Save the file below as
reset-demo.js. No dependencies, and 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.
Create an account. The server mails a confirmation link and does not wait for it.
curl -s -w '[status %{http_code}]\n' -X POST http://127.0.0.1:8968/signup -d 'email=bob@example.test&password=hunter-22'Account created for bob@example.test [status 201] - Step 2.
Read the link the server would have mailed.
grep 'to=bob' mail.log | tail -1MAIL to=bob@example.test subject="Confirm your email" link=http://127.0.0.1:8968/verify?token=vrf-1001Read the token before you spend it.
vrf-1001is a prefix and four digits, which is a counter. Step 6 turns that into an account takeover. - Step 3.
Sign in without opening the link, and read two routes with that session.
curl -s -c jarB.txt -o /dev/null -w 'login -> %{http_code}\n' -X POST http://127.0.0.1:8968/login -d 'email=bob@example.test&password=hunter-22' curl -s -b jarB.txt -o /dev/null -w 'account -> %{http_code}\n' http://127.0.0.1:8968/account curl -s -b jarB.txt -w 'post -> %{http_code}\n' http://127.0.0.1:8968/postlogin -> 200 account -> 200 Verify your email first post -> 403An unconfirmed address signed in and read the account page. One route of the two asks. Run this against every route the product claims is gated: the gate lives in each handler, not in the mail.
- Step 4.
Open the link, then repeat the route that refused.
curl -s -w 'verify -> %{http_code}\n' 'http://127.0.0.1:8968/verify?token=vrf-1001' curl -s -b jarB.txt -w 'post -> %{http_code}\n' http://127.0.0.1:8968/postEmail verified for bob@example.test verify -> 200 Posted post -> 200Verification changed one thing:
403became200on/post. Nothing changed on/account, which was already open. - Step 5.
Open the same link again.
curl -s -w 'second use -> %{http_code}\n' 'http://127.0.0.1:8968/verify?token=vrf-1001'Email verified for bob@example.test second use -> 200The token was not consumed. A confirmation link that stays live is a standing key in a mailbox, and it lets a stale link re-confirm an address the user has since changed.
- Step 6.
Create a second account, then confirm it by guessing its token from the first one.
curl -s -o /dev/null -w 'signup carol -> %{http_code}\n' -X POST http://127.0.0.1:8968/signup -d 'email=carol@example.test&password=hunter-33' curl -s -w 'guess vrf-1002 -> %{http_code}\n' 'http://127.0.0.1:8968/verify?token=vrf-1002'signup carol -> 201 Email verified for carol@example.test guess vrf-1002 -> 200One request confirmed an address whose mail nobody read. Sign up twice and subtract the tokens: a small difference means a counter with a prefix. The same arithmetic works on session identifiers, in How to check session id.
- Step 7.
Sign up on the route that issues a random token, and compare the shape.
curl -s -o /dev/null -X POST http://127.0.0.1:8968/signup-strict -d 'email=dave@example.test&password=hunter-44' grep MAIL mail.log | tail -1MAIL to=dave@example.test subject="Confirm your email" link=http://127.0.0.1:8968/verify?token=49b81244053ea15276c55f6afc6d288532 hex characters from
crypto.randomBytes(16). The value differs every run. Compare the length and the alphabet against step 2. - Step 8.
Use a strict token twice.
curl -s -o /dev/null -X POST http://127.0.0.1:8968/signup-strict -d 'email=frank@example.test&password=hunter-66' T=$(grep -o 'verify?token=[^ ]*' mail.log | tail -1 | cut -d= -f2) curl -s -w 'first use -> %{http_code}\n' "http://127.0.0.1:8968/verify-strict?token=$T" curl -s -w 'replay -> %{http_code}\n' "http://127.0.0.1:8968/verify-strict?token=$T"Email verified for frank@example.test first use -> 200 Unknown token replay -> 400 - Step 9.
Issue one more token, wait past the documented lifetime (2 seconds here), then send it to both handlers.
curl -s -o /dev/null -X POST http://127.0.0.1:8968/signup-strict -d 'email=grace@example.test&password=hunter-77' T=$(grep -o 'verify?token=[^ ]*' mail.log | tail -1 | cut -d= -f2) sleep 3 curl -s -w 'aged, verify-strict -> %{http_code}\n' "http://127.0.0.1:8968/verify-strict?token=$T" curl -s -w 'aged, verify -> %{http_code}\n' "http://127.0.0.1:8968/verify?token=$T"Token expired aged, verify-strict -> 400 Email verified for grace@example.test aged, verify -> 200One token, two handlers, opposite verdicts. The lifetime is a property of the handler, not of the sentence in the mail. 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 link | The token is not consumed | Delete the record in the transaction that sets the verified flag. |
| 400 on the second use | Single use holds | Nothing. This is the result you want. |
| A token that is short, sequential or contains the address | The token is a name, not a secret | Issue at least 16 random bytes and compare them in constant time. |
| Every route serves an unverified account | The flag is written and never read | List the routes that must be gated and assert 403 on each before verification. |
| 200 on a token older than the documented window | The expiry lives in the mail copy only | Compare the issue time on every use. |
| 404 on a link opened from a mail client | The client rewrote or truncated the URL | Compare the URL in the message source with the one the handler received. |
Common mistakes
What to check next
- How to test password reset flow: the other token this server mails, with four defects in one flow.
- How to test expired token: the same expiry question on a bearer token, with the header the client reads.
- How to check session id: the arithmetic from step 6, applied to the identifier in the cookie.
- How to test login functionality: the sign-in that step 3 borrows, tested on its own.
- How to test change password flow: what the account can do once it is confirmed.
FAQ
How to test email confirmation flow?
Six requests: sign up, read the token from the log, sign in without confirming, read a gated route, open the link, open it again. Steps 1 to 5 run that sequence. The third request tells you whether the flow does anything.
How to test a single use verification token?
Send the same token twice and compare the two statuses, as steps 5 and 8 do. 200 then 400 is a pass. 200 twice means the record survives use. Do the comparison on a fresh account, because a token that already failed proves nothing.
How to test magic link login?
The same way, with one addition: a magic link creates a session, so check what the second use creates. Open it twice and compare the Set-Cookie header on both responses. Two sessions from one mail is the finding, and the window matters more here.
How do I test the link without a real mailbox?
Read the token where the application writes it: the log, a file transport, or a catch-all inbox. Step 2 reads stdout. Delivery and rendering are separate checks, and neither tells you whether the token is single use.
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
intermediate8 minpublished updated Maks Verny