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?