hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Testing Auth and Security with @hectoday/http

Why Auth Tests Are Different

  • Testing Security, Not Just Functionality
  • Project Setup

Testing Authentication

  • Testing Login Flows
  • Testing Sessions and Cookies
  • Testing 2FA Flows

Testing Authorization

  • Testing Access Boundaries
  • Testing API Keys and Scopes

Testing Security Properties

  • Testing Rate Limiting and Lockout
  • Testing Token Security
  • Testing Input Handling

Putting It Together

  • A Security Test Suite

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?

← Project Setup Testing Sessions and Cookies →

© 2026 hectoday. All rights reserved.