How to test login functionality
Post the credentials from a client with an empty cookie jar: curl -i -X POST http://127.0.0.1:8946/login -d 'username=alice&password=correct-horse' answers 302 and a Set-Cookie: sid= header. Send that cookie to a protected route and read the status. A live session returns 200, a missing or invented one returns 401.
Why check this
Run this before any release that touches authentication, and again on staging after a change to the session store, the reverse proxy or the cookie domain. The failure it prevents is a login that looks correct because the browser you tested in already held a session from an earlier run. A client with an empty jar is the only client that exercises the whole path: form post, credential comparison, session creation, cookie issue, protected route.
The check settles four facts. Whether correct credentials create a session. Whether wrong credentials are refused with a status a client can act on. Whether the protected route refuses a request carrying no session. Whether it refuses a session id that was never issued. Lockout after repeated failures, password rules and concurrent sessions are separate procedures, and a passing login check is not evidence for any of them.
Prerequisites
- Node 22 or later. Save the file below as
auth-demo.js. It has one account, one protected route and two logout routes, and no dependencies. - curl 8 or later. No HTTP/2 support is needed here. See the curl manual.
- The MDN page on Set-Cookie for the attribute names used below.
// auth-demo.js - local target for the login and logout checks. Node 22, no dependencies.
const http = require('node:http');
const crypto = require('node:crypto');
const USERS = { alice: 'correct-horse' };
const sessions = new Map(); // sid -> username
const FORM = `<!doctype html><title>Demo login</title><form method="post" action="/login">
<input name="username"><input name="password" type="password"><button>Sign in</button></form>`;
const sid = (req) => (/(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '') || [])[1] || null;
const body = (req) => new Promise((r) => { let s = ''; req.on('data', (d) => { s += d; }); req.on('end', () => r(s)); });
const text = { 'content-type': 'text/plain' };
http.createServer(async (req, res) => {
const p = new URL(req.url, 'http://127.0.0.1').pathname;
if (p === '/') return res.writeHead(200, { 'content-type': 'text/html' }).end(FORM);
if (p === '/login' && req.method === 'POST') {
const f = new URLSearchParams(await body(req));
const u = f.get('username') || '', pw = f.get('password') || '';
if (!Object.hasOwn(USERS, u)) return res.writeHead(401, text).end(`No account for ${u}\n`);
if (USERS[u] !== pw) return res.writeHead(401, text).end('Wrong password\n');
const id = crypto.randomBytes(16).toString('hex');
sessions.set(id, u);
return res.writeHead(302, { 'set-cookie': `sid=${id}; Path=/; HttpOnly; SameSite=Lax`, location: '/account' }).end();
}
if (p === '/account') {
if (req.method !== 'GET') return res.writeHead(405, { ...text, allow: 'GET' }).end('Method not allowed\n');
const id = sid(req);
if (!id || !sessions.has(id)) return res.writeHead(401, text).end('Not signed in\n');
return res.writeHead(200, text).end(`Signed in as ${sessions.get(id)}\n`);
}
// The defect under test: logout clears the browser cookie and keeps the server record.
if (p === '/logout' && req.method === 'POST')
return res.writeHead(302, { 'set-cookie': 'sid=; Path=/; Max-Age=0', location: '/' }).end();
// The fix: drop the server-side session as well.
if (p === '/logout-server' && req.method === 'POST') {
sessions.delete(sid(req));
return res.writeHead(302, { 'set-cookie': 'sid=; Path=/; Max-Age=0', location: '/' }).end();
}
return res.writeHead(404, text).end('Not found\n');
}).listen(8946, () => console.log('auth demo on http://127.0.0.1:8946'));
Start it and keep the process id. Stop that id when you finish, never every process called node.
node auth-demo.js & SRV=$!
Steps
- Step 1.
Post correct credentials and read the status line and the cookie, without following the redirect.
curl -s -i -X POST http://127.0.0.1:8946/login -d 'username=alice&password=correct-horse' | head -4HTTP/1.1 302 Found set-cookie: sid=189b85b37bf6a5be84389163334c149b; Path=/; HttpOnly; SameSite=Lax location: /account Date: Fri, 11 Sep 2026 20:19:13 GMTThree facts are here: the status the server uses for a successful login, the cookie name, and the attributes the session cookie carries.
- Step 2.
Repeat the login into a cookie jar, then spend the cookie on the protected route.
curl -s -c jar.txt -o /dev/null -w 'login %{http_code} -> %{redirect_url}\n' -X POST http://127.0.0.1:8946/login -d 'username=alice&password=correct-horse' && curl -s -b jar.txt -w '\n[status %{http_code}]\n' http://127.0.0.1:8946/accountlogin 302 -> http://127.0.0.1:8946/account Signed in as alice [status 200] - Step 3.
Send a real account name with a wrong password. Print the body and its byte count.
curl -s -w '[status %{http_code}] [bytes %{size_download}]\n' -X POST http://127.0.0.1:8946/login -d 'username=alice&password=wrong'Wrong password [status 401] [bytes 15] - Step 4.
Send a name that has no account, with the same wrong password, and compare the two answers.
curl -s -w '[status %{http_code}] [bytes %{size_download}]\n' -X POST http://127.0.0.1:8946/login -d 'username=alicia&password=wrong'No account for alicia [status 401] [bytes 22]The status matches step 3 and the body does not. 15 bytes against 22 bytes separates a real account from an invented one, and the difference survives even if nobody reads the text.
- Step 5.
Ask for the protected route with no cookie at all.
curl -s -w '\n[status %{http_code}]\n' http://127.0.0.1:8946/accountNot signed in [status 401] - Step 6.
Send a session id of the right shape that the server never issued.
curl -s -H 'Cookie: sid=00000000000000000000000000000000' -w '\n[status %{http_code}]\n' http://127.0.0.1:8946/accountNot signed in [status 401]Stop the server when the run is finished:
kill $SRV.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| 302 plus set-cookie: sid=... | Credentials matched and a session record exists | Nothing. Carry the cookie into step 2. |
| 200 on the protected route with the jar | The cookie is accepted and bound to the account | Nothing. This is the shape you want. |
| 401 on the protected route with a fresh jar | The cookie was issued but not sent back | Compare Path and Domain on the cookie with the host in your request. |
| 200 on the protected route with no cookie | The route has no session check | A defect in the route, not in login. Report it against the route. |
| Two refusals with different bodies or byte counts | The answer names which accounts exist | Make both refusals identical in status, body and length. |
| 500 on wrong credentials | The failure path throws instead of refusing | Read the server log. An exception here often leaks a stack trace to the client. |
Common mistakes
What to check next
- How to test login with curl: the cookie jar mechanics behind step 2, including what
--locationdoes to this login. - How to check if session expires after logout: the same session, replayed after the user signs out.
- How to check if cookies are secure and HttpOnly: the attributes step 1 printed, read as a security property.
- How to check SameSite cookie attribute: whether the session cookie rides along on a cross-site request.
- How to test API authentication: the header-based equivalent when the client is not a browser.
FAQ
How to test a login page manually?
Open the page in a private window so the jar is empty. Submit correct credentials and watch the Network tab for the POST, its status and its Set-Cookie header. Then submit a wrong password and compare. The private window is what makes the result repeatable.
How to write test cases for a login page?
One case per row of the table above: correct credentials, wrong password, unknown account, empty submission, protected route without a session, protected route with an invented session id. Each case asserts on a status and on a body, because step 4 shows the status alone can pass while the body leaks.
How to test login with invalid credentials?
Send the request anyway and read the answer. Step 3 and step 4 are the two cases worth separating: a real account with a wrong password, and a name with no account. The server should answer both the same way, in status, in text and in length.
Does a 302 mean the login succeeded?
Not on its own. A redirect with no Set-Cookie header is a login that failed and sent you back to the form. The evidence is the cookie, not the status, which is why step 1 reads the headers instead of following the hop.
Should the error message say which field was wrong?
No. Naming the field turns the form into an account lookup, as step 4 shows with two byte counts. One message for every failure, with the same length, keeps that lookup closed.
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
basic6 minpublished updated Maks Verny