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 API Keys and Scopes

API keys have their own attack surface

API keys bypass the session system. They need their own set of tests to verify that they work correctly and fail correctly.

Basic API key tests

describe("API key authentication", () => {
  let apiKey: string;

  // Create a key before tests
  before(async () => {
    const cookie = await loginAs("[email protected]");
    // Step-up if required...
    const res = await authenticatedRequest("/orgs/org-acme/api-keys", cookie, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ name: "Test Key", scopes: ["notes:read", "notes:create"] }),
    });
    const body = await res.json();
    apiKey = body.key;
  });

  it("valid key accesses protected routes", async () => {
    const res = await request("/orgs/org-acme/notes", {
      headers: { "x-api-key": apiKey },
    });
    assert.strictEqual(res.status, 200);
  });

  it("invalid key returns 401", async () => {
    const res = await request("/orgs/org-acme/notes", {
      headers: { "x-api-key": "sk_invalid_key_12345" },
    });
    assert.strictEqual(res.status, 401);
  });

  it("no key and no session returns 401", async () => {
    const res = await request("/orgs/org-acme/notes");
    assert.strictEqual(res.status, 401);
  });
});

Scope tests

describe("API key scopes", () => {
  it("key with notes:read can list notes", async () => {
    const res = await request("/orgs/org-acme/notes", {
      headers: { "x-api-key": apiKey }, // scopes: ["notes:read", "notes:create"]
    });
    assert.strictEqual(res.status, 200);
  });

  it("key with notes:create can create notes", async () => {
    const res = await request("/orgs/org-acme/notes", {
      method: "POST",
      headers: {
        "x-api-key": apiKey,
        "content-type": "application/json",
      },
      body: JSON.stringify({ title: "Via API Key", body: "Created by key" }),
    });
    assert.strictEqual(res.status, 201);
  });

  it("key without notes:delete cannot delete notes", async () => {
    const res = await request("/orgs/org-acme/notes/note-1", {
      method: "DELETE",
      headers: { "x-api-key": apiKey },
    });
    assert.strictEqual(res.status, 403);
  });
});

These test the intersection rule: the key’s effective permissions are the intersection of the user’s role permissions and the key’s scopes. Even if Alice is an owner (can delete), the key’s scopes do not include notes:delete.

Org scoping tests

describe("API key org binding", () => {
  it("key bound to org-acme cannot access org-globex", async () => {
    const res = await request("/orgs/org-globex/notes", {
      headers: { "x-api-key": apiKey },
    });
    // Should fail — key is bound to Acme, not Globex
    assert.ok([403, 404].includes(res.status), "Key should not access a different org");
  });
});

Key lifecycle tests

describe("API key lifecycle", () => {
  it("key is only shown once at creation", async () => {
    const cookie = await loginAs("[email protected]");
    const createRes = await authenticatedRequest("/orgs/org-acme/api-keys", cookie, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ name: "One-Time Key", scopes: ["notes:read"] }),
    });
    const body = await createRes.json();
    assert.ok(body.key, "Key should be returned at creation");
    assert.ok(body.key.startsWith("sk_"), "Key should have sk_ prefix");

    // There should be no endpoint to retrieve the key later
    // (This is a design test, not a runtime test)
  });

  it("cannot create key with scopes the user does not have", async () => {
    const bobCookie = await loginAs("[email protected]"); // editor, no org:delete
    const res = await authenticatedRequest("/orgs/org-acme/api-keys", bobCookie, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ name: "Escalation", scopes: ["org:delete"] }),
    });
    assert.strictEqual(
      res.status,
      403,
      "Cannot create key with more permissions than the user has",
    );
  });
});

The escalation test catches privilege escalation via API keys: a user should not be able to create a key with permissions they do not have.

Exercises

Exercise 1: Write all the tests above. Run them against your app.

Exercise 2: Create a read-only key (scopes: ["notes:read"]). Write tests that verify it can read but cannot create, edit, or delete.

Exercise 3: Revoke an API key (delete from database). Test that the key no longer works.

Why do we test that a key cannot be created with scopes the user does not have?

← Testing Access Boundaries Testing Rate Limiting and Lockout →

© 2026 hectoday. All rights reserved.