A Security Test Suite
Organizing tests by threat
Functional tests are organized by feature: “login tests,” “notes tests,” “settings tests.” Security tests are better organized by threat: “what could go wrong?”
tests/
auth.test.ts # Login, sessions, 2FA — authentication threats
authz.test.ts # IDOR, roles, permissions — authorization threats
security.test.ts # Injection, XSS, traversal — input handling threats
abuse.test.ts # Rate limiting, lockout — abuse protection
tokens.test.ts # Rotation, reuse, deny list — token threats
helpers.ts # Login, request, cookie extraction
payloads.ts # Standard attack payloads Each file answers one question: “Is this category of threat handled?” When a test fails, the filename tells you what type of vulnerability was introduced.
The minimum test set
Not every app needs hundreds of security tests. But every app with auth needs these:
Authentication (must have)
- Wrong password returns 401
- Non-existent email returns 401 (same as wrong password)
- Session cookie has HttpOnly and SameSite attributes
- Invalid session ID returns 401
- Logout invalidates the session
Authorization (must have)
- User A cannot access user B’s data (IDOR)
- Non-member gets 404 for org resources (not 403)
- Viewer cannot create/delete (role enforcement)
- Extra fields in request body are ignored (mass assignment)
Abuse protection (should have)
- Rate limiting triggers at the configured threshold
- Lockout triggers after N failures
- Correct password fails during lockout
Token security (should have if using JWTs)
- Expired access token is rejected
- Used refresh token is rejected
- Refresh token reuse invalidates the family
Input handling (should have)
- SQL injection payloads do not leak data
- XSS payloads are encoded in HTML responses
This is 17 tests. They cover the highest-impact threats. Add more as your app grows.
Running on CI
Add security tests to your CI pipeline:
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npx tsx --test tests/*.test.ts Security tests should run on every pull request. A failing security test blocks the merge.
When security tests save you
Scenario 1: A developer refactors the notes route and forgets to include org_id in the query. The IDOR test fails. The PR is blocked. The developer adds the org_id check. Bug never ships.
Scenario 2: A developer adds a new field to the note creation body and uses body.user_id ?? user.id (copied from old code). The mass assignment test fails. The developer switches to explicit field picking. Bug never ships.
Scenario 3: A developer changes the session cookie helper and removes SameSite to fix a cross-origin issue. The cookie attribute test fails. The developer finds a different fix. CSRF vulnerability never ships.
In each case, the test caught a security regression introduced by a well-intentioned code change. This is the core value of security tests: they catch mistakes by people who are not thinking about security at that moment.
What we covered in this course
| Category | What we tested | Why it matters |
|---|---|---|
| Login flows | Valid, invalid, missing, timing | Catches enumeration and timing leaks |
| Sessions | Cookie attrs, lifecycle, rotation | Catches XSS and CSRF enablers |
| 2FA | Pending sessions, codes, recovery | Catches 2FA bypass |
| Access boundaries | IDOR, roles, permissions, org scoping | Catches unauthorized access |
| API keys | Scopes, org binding, escalation | Catches privilege escalation |
| Rate limiting | Thresholds, lockout, headers | Catches brute-force enablers |
| Tokens | Rotation, reuse, deny list | Catches token theft impacts |
| Input handling | Injection, XSS, traversal, assignment | Catches input-based attacks |
Every test answers: “Does this defense still work after the latest code change?”
Exercises
Exercise 1: Create the minimum test set (17 tests from the checklist above). Run them against your app. How many pass?
Exercise 2: Add the tests to a CI pipeline. Make a PR that breaks one test. Verify the pipeline catches it.
Exercise 3: Review your app’s routes. Are there any security properties that are not covered by your tests? Add tests for them.
Exercise 4: Break a defense intentionally (remove an org_id check, remove HttpOnly from a cookie, remove the timing dummy hash). Run the tests. The relevant test should fail. Fix it and verify the test passes again.
When is the most valuable time for a security test to fail?