Testing Token Security
Tokens have complex lifecycle rules
Refresh tokens rotate, reused tokens invalidate families, denied access tokens are rejected, expired tokens fail. Each rule needs a test.
Refresh token rotation tests
describe("refresh token rotation", () => {
it("refresh returns new access and refresh tokens", async () => {
// Login to get initial tokens
const loginRes = await request("/token/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: "[email protected]", password: "password123" }),
});
const { accessToken, refreshToken } = await loginRes.json();
// Refresh
const refreshRes = await request("/token/refresh", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
assert.strictEqual(refreshRes.status, 200);
const newTokens = await refreshRes.json();
assert.ok(newTokens.accessToken, "Should return new access token");
assert.ok(newTokens.refreshToken, "Should return new refresh token");
assert.notStrictEqual(newTokens.refreshToken, refreshToken, "New refresh token must differ");
});
it("old refresh token is invalid after rotation", async () => {
const loginRes = await request("/token/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: "[email protected]", password: "password123" }),
});
const { refreshToken } = await loginRes.json();
// Use it once (rotation)
await request("/token/refresh", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
// Try to use the old one again
const res = await request("/token/refresh", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
assert.strictEqual(res.status, 401, "Old refresh token must be rejected");
});
}); Reuse detection tests
describe("refresh token reuse detection", () => {
it("reusing a consumed token invalidates the entire family", async () => {
const loginRes = await request("/token/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: "[email protected]", password: "password123" }),
});
const { refreshToken: token1 } = await loginRes.json();
// Rotate: token1 → token2
const refresh1 = await request("/token/refresh", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ refreshToken: token1 }),
});
const { refreshToken: token2 } = await refresh1.json();
// Reuse token1 (the consumed one)
const reuseRes = await request("/token/refresh", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ refreshToken: token1 }),
});
assert.strictEqual(reuseRes.status, 401, "Reused token must be rejected");
// Token2 should also be invalidated (family revocation)
const token2Res = await request("/token/refresh", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ refreshToken: token2 }),
});
assert.strictEqual(token2Res.status, 401, "Entire family must be invalidated on reuse");
});
}); This is the most sophisticated token test. It verifies that when a consumed refresh token is reused (indicating theft), the entire token family is invalidated — both the old token and the new one.
Access token deny list tests
describe("access token deny list", () => {
it("denied token is rejected even if not expired", async () => {
const loginRes = await request("/token/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: "[email protected]", password: "password123" }),
});
const { accessToken } = await loginRes.json();
// Verify it works
const beforeRes = await request("/token/me", {
headers: { authorization: `Bearer ${accessToken}` },
});
assert.strictEqual(beforeRes.status, 200);
// Deny it (via admin action or password change)
// This depends on your deny mechanism — might need a direct function call
// denyToken(jti, expiresAt);
// Verify it is rejected
// const afterRes = await request("/token/me", {
// headers: { authorization: `Bearer ${accessToken}` },
// });
// assert.strictEqual(afterRes.status, 401);
});
}); [!NOTE] Testing the deny list requires either an admin endpoint to deny specific tokens or direct access to the
denyTokenfunction. In a real test suite, you would import the function and call it directly, then verify the token fails authentication.
Exercises
Exercise 1: Write the rotation and reuse detection tests. These are the highest-value token tests.
Exercise 2: Test that an expired access token (after the 15-minute TTL) is rejected. You can temporarily set the TTL to 1 second for testing.
Exercise 3: Write a test that verifies two independent logins have separate token families. Reusing a token from login 1 should not affect login 2’s tokens.
Why does the reuse detection test check that token2 is also invalidated?