Testing Login Flows
The login endpoint has the most security tests
Login is the front door. It is the most attacked endpoint. Every defense the Securing Your API course added (rate limiting, lockout, timing protection) needs a test.
Functional tests
import { describe, it } from "node:test";
import assert from "node:assert";
import { login, getCookie, request } from "./helpers.js";
describe("POST /login", () => {
it("valid credentials return 200 and a session cookie", async () => {
const res = await login("[email protected]", "password123");
assert.strictEqual(res.status, 200);
const cookie = getCookie(res);
assert.ok(cookie.startsWith("session="), "Should set a session cookie");
const body = await res.json();
assert.strictEqual(body.user.email, "[email protected]");
});
}); One test. Now the security tests.
Security tests
describe("POST /login — security", () => {
it("wrong password returns 401", async () => {
const res = await login("[email protected]", "wrongpassword");
assert.strictEqual(res.status, 401);
assert.ok(!getCookie(res), "Should not set a session cookie");
});
it("non-existent email returns 401 (same as wrong password)", async () => {
const res = await login("[email protected]", "anything");
assert.strictEqual(res.status, 401);
// The error message should be identical to wrong-password
const body = await res.json();
assert.ok(body.error.includes("Invalid"), "Should use a generic error, not 'user not found'");
});
it("missing email returns 400", async () => {
const res = await request("/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ password: "test" }),
});
assert.strictEqual(res.status, 400);
});
it("missing password returns 400", async () => {
const res = await request("/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: "[email protected]" }),
});
assert.strictEqual(res.status, 400);
});
it("empty body returns 400", async () => {
const res = await request("/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
});
assert.strictEqual(res.status, 400);
});
}); Five tests for the failure cases. These catch: email enumeration (different error messages for existing vs non-existing), missing validation (accepting partial input), and information leakage (returning details about what went wrong).
Timing tests
describe("POST /login — timing", () => {
it("existing and non-existing users take similar time", async () => {
// Warm up (first request might be slower due to module loading)
await login("[email protected]", "wrong");
const times: { existing: number[]; nonExisting: number[] } = {
existing: [],
nonExisting: [],
};
for (let i = 0; i < 5; i++) {
const t1 = performance.now();
await login("[email protected]", "wrong");
times.existing.push(performance.now() - t1);
const t2 = performance.now();
await login("nobody-" + i + "@example.com", "wrong");
times.nonExisting.push(performance.now() - t2);
}
const avgExisting = times.existing.reduce((a, b) => a + b) / times.existing.length;
const avgNonExisting = times.nonExisting.reduce((a, b) => a + b) / times.nonExisting.length;
// Both should include bcrypt time (~100ms). Allow 50ms tolerance.
assert.ok(
Math.abs(avgExisting - avgNonExisting) < 50,
`Timing difference too large: existing=${avgExisting.toFixed(0)}ms, non-existing=${avgNonExisting.toFixed(0)}ms`,
);
});
}); This test catches the timing attack from the Securing Your API course. Without the dummy hash, non-existing users return instantly while existing users take ~100ms for bcrypt. The test measures the difference.
[!NOTE] Timing tests are inherently noisy (CPU load, garbage collection, other processes). Use multiple iterations and average. Allow reasonable tolerance (50ms). These tests are best run in a consistent environment, not alongside heavy workloads.
Exercises
Exercise 1: Write all the tests above. Run them. Do they pass against your app?
Exercise 2: Temporarily remove the dummy hash from the login route (the timing attack prevention). Run the timing test. Does it fail?
Exercise 3: Add a test for SQL injection in the login email field: send ' OR '1'='1 as the email. The login should fail with 400 or 401, not succeed.
Why do we test that non-existent emails return 401 instead of 404?