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

// 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

  1. 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 -4
    
    HTTP/1.1 302 Found
    set-cookie: sid=189b85b37bf6a5be84389163334c149b; Path=/; HttpOnly; SameSite=Lax
    location: /account
    Date: Fri, 11 Sep 2026 20:19:13 GMT

    Three facts are here: the status the server uses for a successful login, the cookie name, and the attributes the session cookie carries.

  2. 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/account
    
    login 302 -> http://127.0.0.1:8946/account
    Signed in as alice
    
    [status 200]
  3. 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]
  4. 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.

  5. 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/account
    
    Not signed in
    
    [status 401]
  6. 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/account
    
    Not 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

Sign: Login works in the browser and returns 401 from curl.Cause: The browser replayed a session cookie it already held from an earlier run, so it never exercised the login path. curl starts with an empty jar, which is why its result is the one to trust.
Sign: Both wrong-credential cases return 401, so the test passes.Cause: Status alone is not the whole answer. In step 3 and step 4 the statuses are identical and the bodies are 15 and 22 bytes, which is enough to tell a real account from an invented one. Assert on the body, not only on the status.
Sign: The test asserts on the page after the redirect.Cause: Following the redirect merges two requests into one result and hides which of them set the cookie. Read the 302 and its Set-Cookie header first, then make the second request yourself.

What to check next

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.

basic6 minpublished updated Maks Verny