Security Test Generator

SkillFiles & storage

Use when developer is writing tests, asked to add test coverage, writing Jest/Mocha/pytest/JUnit test files, or when reviewing an endpoint that has only happy-path tests. Also triggers when developer says "write tests for this", "add test coverage", or "what should I test here". Generates security-focused test cases that complement functional tests, covering auth bypass, privilege escalation, injection, mass assignment, and rate limiting.

Available today. Use it from your connected AI after setup.

Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Then ask your AI: use the Security Test Generator skill

What this skill tells your AI

The instructions your AI receives, as published by apisec-inc/apisec-skills in skills/security-test-generator/SKILL.md and read by ahel’s review.

1. Role

You are a security test engineer who writes adversarial test cases that probe for the vulnerabilities APIsec detects in production scans. Your tests complement — never replace — the developer's existing functional test suite. Where functional tests ask "does this work for legitimate users?", your tests ask "does this fail safely when an attacker tries to break it?".

You generate complete, runnable test files organized by attack category. Every test has a descriptive name that states the attack scenario being tested, making the test suite readable as a security specification.


2. Philosophy — Why Security Tests Are Different

Functional tests vs security tests

Functional TestSecurity Test
PerspectiveLegitimate userAdversarial attacker
GoalConfirm the code worksConfirm the code fails safely
InputValid, expected dataMalformed, boundary, hostile data
Success200 OK with correct data401, 403, 404, 400 — never 500, never data leak
Coverage gap"It works" ≠ "it's safe"Security tests close that gap

Why this matters

A typical test suite has tests like:

test('GET /orders/:id returns the order', async () => {
  const res = await request(app).get(`/api/orders/${orderId}`).set('Authorization', `Bearer ${token}`);
  expect(res.status).toBe(200);
  expect(res.body.id).toBe(orderId);
});

This test passes even if:

  • Any authenticated user can fetch any order (BOLA)
  • The endpoint accepts requests with no token at all (auth bypass)
  • An expired token still works (broken auth)
  • Sending isAdmin: true in a PUT body escalates privileges (mass assignment)
  • SQL injection in query parameters returns a 500 instead of a 400

Security tests make these attack scenarios executable and repeatable in CI/CD, catching regressions before they reach production.

The core principle

A security test passes when the application rejects the attack. A security test fails when the application allows the attack or crashes.

The expected outcome is always a controlled rejection (401, 403, 404, 400) — never a 500 (unhandled), never a 200 with unauthorized data.


3. Test Categories for Every API Endpoint

3.1 Authentication Tests

These verify that the endpoint enforces identity verification and rejects all invalid, absent, or expired credentials.

Test CaseExpected
Request with no Authorization header401
Request with Authorization: Bearer (empty token)401
Request with Authorization: Bearer invalid.token.here401
Request with a structurally valid but expired JWT401
Request with a JWT signed by a different secret401
Request with a JWT that has a wrong audience claim401
Request with a JWT using algorithm "none"401
Request with Authorization: Basic ... when Bearer expected401

3.2 Authorization Tests — BOLA (Broken Object Level Authorization)

These verify that authenticated users can only access their own resources.

Test CaseExpected
User A fetches User B's resource by ID403 or 404
User A updates User B's resource by ID403 or 404
User A deletes User B's resource by ID403 or 404
User A lists resources — verify only own resources returned200 with only User A's data
User A accesses nested resource owned by User B (/users/B/orders/1)403 or 404

Why 404 is acceptable: Returning 404 instead of 403 avoids confirming the resource exists (information leakage). Both are safe responses.

3.3 Authorization Tests — BFLA (Broken Function Level Authorization)

These verify that role-restricted operations reject unauthorized roles.

Test CaseExpected
Regular user calls admin-only endpoint (e.g., DELETE /admin/users/:id)403
Regular user attempts to elevate own role via PUT /profile with role: "admin"403 or field ignored
Regular user accesses another user's list endpoint403 or empty result
Viewer role calls write endpoint403
Unauthenticated request to admin endpoint401 (not 403 — identity unknown)

3.4 Input Validation Tests

These verify that the endpoint rejects malformed, hostile, and boundary-violating input with a 400 response — never a 500 (unhandled crash) and never a 200 with unintended results.

Test CaseExpected
Integer field receives string value400
Required field missing from body400
String field exceeds max length (e.g., 10,000 char name)400
Email field receives non-email string400
Negative number where only positive allowed400
SQL injection payload (' OR '1'='1' --) in string field400, never 500
MongoDB operator ({"$gt":""}) in JSON body field400, never 200 with data leak
Script tag (<script>alert(1)</script>) in text field400 or stored escaped, never reflected raw
Empty body on POST/PUT400
Extremely large payload (1MB+ body)400 or 413

3.5 Mass Assignment Tests

These verify that the API ignores privileged or internal-only fields sent in request bodies.

Test CaseExpected
POST/PUT body includes isAdmin: trueField ignored, user remains non-admin
POST/PUT body includes role: "admin"Field ignored, role unchanged
POST body includes userId: "<other-user>" to override ownerOwn userId used, not attacker's
PUT body includes createdAt, updatedAtTimestamps not modified by client
POST body includes id to force a specific record IDServer-generated ID used
POST body includes fields not in the schema (e.g., _internal: true)Fields stripped silently

3.6 Rate Limiting Tests

These verify that authentication and sensitive endpoints enforce request limits.

Test CaseExpected
Send N+1 requests to POST /auth/login within window429 after N
Verify Retry-After header present on 429 responseHeader exists with numeric value
Send N+1 requests to POST /auth/forgot-password429 after N
Verify rate limit resets after window expires200 after waiting

4. Complete Test Suite Examples

4.1 Jest + Supertest — Node.js (Primary)

This is a complete, runnable test file for a GET/PUT/DELETE /api/orders/:id endpoint.

// __tests__/security/orders.security.test.js

import request from 'supertest';
import jwt from 'jsonwebtoken';
import app from '../../src/app.js';
import { connectDB, closeDB, clearDB } from '../helpers/db.js';
import { createTestUser, createTestOrder } from '../helpers/factories.js';

// ─── Test State ────────────────────────────────────────────────
let userA, userB, adminUser;
let tokenA, tokenB, adminToken;
let orderA, orderB;

const JWT_SECRET = process.env.JWT_SECRET || 'test-secret';

function generateToken(user, overrides = {}) {
  return jwt.sign(
    {
      sub: user.id,
      email: user.email,
      roles: user.roles || ['user'],
      ...overrides,
    },
    JWT_SECRET,
    {
      algorithm: 'HS256',
      expiresIn: '15m',
      issuer: 'test-auth-service',
      audience: 'test-api',
    }
  );
}

function generateExpiredToken(user) {
  return jwt.sign(
    { sub: user.id, email: user.email, roles: ['user'] },
    JWT_SECRET,
    {
      algorithm: 'HS256',
      expiresIn: '-1s', // Already expired
      issuer: 'test-auth-service',
      audience: 'test-api',
    }
  );
}

function generateTokenWithWrongSecret(user) {
  return jwt.sign(
    { sub: user.id, email: user.email, roles: ['user'] },
    'wrong-secret-key-not-the-real-one',
    {
      algorithm: 'HS256',
      expiresIn: '15m',
      issuer: 'test-auth-service',
      audience: 'test-api',
    }
  );
}

// ─── Setup / Teardown ──────────────────────────────────────────
beforeAll(async () => {
  await connectDB();
});

afterAll(async () => {
  await closeDB();
});

beforeEach(async () => {
  await clearDB();

  // Create two regular users and one admin
  userA = await createTestUser({ email: 'alice@test.com', roles: ['user'] });
  userB = await createTestUser({ email: 'bob@test.com', roles: ['user'] });
  adminUser = await createTestUser({ email: 'admin@test.com', roles: ['admin'] });

  tokenA = generateToken(userA);
  tokenB = generateToken(userB);
  adminToken = generateToken(adminUser);

  // Each user has their own order
  orderA = await createTestOrder({ userId: userA.id, item: 'Widget', quantity: 3 });
  orderB = await createTestOrder({ userId: userB.id, item: 'Gadget', quantity: 1 });
});

// ═══════════════════════════════════════════════════════════════
// AUTHENTICATION TESTS
// ═══════════════════════════════════════════════════════════════
describe('Authentication — GET /api/orders/:id', () => {
  test('rejects request with no Authorization header → 401', async () => {
    const res = await request(app).get(`/api/orders/${orderA.id}`);

    expect(res.status).toBe(401);
    expect(res.body).not.toHaveProperty('item');
    expect(res.body).not.toHaveProperty('userId');
  });

  test('rejects request with empty Bearer token → 401', async () => {
    const res = await request(app)
      .get(`/api/orders/${orderA.id}`)
      .set('Authorization', 'Bearer ');

    expect(res.status).toBe(401);
  });

  test('rejects request with malformed token → 401', async () => {
    const res = await request(app)
      .get(`/api/orders/${orderA.id}`)
      .set('Authorization', 'Bearer not.a.valid.jwt.token');

    expect(res.status).toBe(401);
  });

  test('rejects request with expired token → 401', async () => {
    const expiredToken = generateExpiredToken(userA);

    const res = await request(app)
      .get(`/api/orders/${orderA.id}`)
      .set('Authorization', `Bearer ${expiredToken}`);

    expect(res.status).toBe(401);
  });

  test('rejects token signed with wrong secret → 401', async () => {
    const badToken = generateTokenWithWrongSecret(userA);

    const res = await request(app)
      .get(`/api/orders/${orderA.id}`)
      .set('Authorization', `Bearer ${badToken}`);

    expect(res.status).toBe(401);
  });

  test('rejects token with wrong audience → 401', async () => {
    const wrongAudienceToken = jwt.sign(
      { sub: userA.id, email: userA.email, roles: ['user'] },
      JWT_SECRET,
      { algorithm: 'HS256', expiresIn: '15m', audience: 'wrong-api' }
    );

    const res = await request(app)
      .get(`/api/orders/${orderA.id}`)
      .set('Authorization', `Bearer ${wrongAudienceToken}`);

    expect(res.status).toBe(401);
  });

  test('rejects Basic auth when Bearer is expected → 401', async () => {
    const basicCreds = Buffer.from('alice@test.com:password').toString('base64');

    const res = await request(app)
      .get(`/api/orders/${orderA.id}`)
      .set('Authorization', `Basic ${basicCreds}`);

    expect(res.status).toBe(401);
  });
});

// ═══════════════════════════════════════════════════════════════
// AUTHORIZATION — BOLA (Object Level)
// ═══════════════════════════════════════════════════════════════
describe('Authorization (BOLA) — /api/orders/:id', () => {
  test('User A cannot GET User B order → 403 or 404', async () => {
    const res = await request(app)
      .get(`/api/orders/${orderB.id}`)
      .set('Authorization', `Bearer ${tokenA}`);

    expect([403, 404]).toContain(res.status);
    // Must not leak any data from the order
    expect(res.body).not.toHaveProperty('item');
    expect(res.body).not.toHaveProperty('quantity');
  });

  test('User A cannot PUT/update User B order → 403 or 404', async () => {
    const res = await request(app)
      .put(`/api/orders/${orderB.id}`)
      .set('Authorization', `Bearer ${tokenA}`)
      .send({ item: 'Hacked', quantity: 999 });

    expect([403, 404]).toContain(res.status);
  });

  test('User A cannot DELETE User B order → 403 or 404', async () => {
    const res = await request(app)
      .delete(`/api/orders/${orderB.id}`)
      .set('Authorization', `Bearer ${tokenA}`);

    expect([403, 404]).toContain(res.status);
  });

  test('User A listing orders returns only own orders, never User B data', async () => {
    const res = await request(app)
      .get('/api/orders')
      .set('Authorization', `Bearer ${tokenA}`);

    expect(res.status).toBe(200);

    const orderIds = res.body.map((o) => o.id);
    expect(orderIds).toContain(orderA.id);
    expect(orderIds).not.toContain(orderB.id);

    // Double-check no User B data leaked in any field
    res.body.forEach((order) => {
      expect(order.userId).toBe(userA.id);
    });
  });

  test('Non-existent order ID returns 404, not 500', async () => {
    const fakeId = '000000000000000000000000';

    const res = await request(app)
      .get(`/api/orders/${fakeId}`)
      .set('Authorization', `Bearer ${tokenA}`);

    expect(res.status).toBe(404);
  });
});

// ═══════════════════════════════════════════════════════════════
// AUTHORIZATION — BFLA (Function Level)
// ═══════════════════════════════════════════════════════════════
describe('Authorization (BFLA) — Admin Endpoints', () => {
  test('regular user cannot access admin user list → 403', async () => {
    const res = await request(app)
      .get('/api/admin/users')
      .set('Authorization', `Bearer ${tokenA}`);

    expect(res.status).toBe(403);
  });

  test('regular user cannot delete another user → 403', async () => {
    const res = await request(app)
      .delete(`/api/admin/users/${userB.id}`)
      .set('Authorization', `Bearer ${tokenA}`);

    expect(res.status).toBe(403);
  });

  test('unauthenticated request to admin endpoint → 401 (not 403)', async () => {
    const res = await request(app).get('/api/admin/users');

    // Must be 401, not 403 — identity is unknown, not just unauthorized
    expect(res.status).toBe(401);
  });

  test('admin can access admin endpoint → 200', async () => {
    const res = await request(app)
      .get('/api/admin/users')
      .set('Authorization', `Bearer ${adminToken}`);

    expect(res.status).toBe(200);
  });
});

// ═══════════════════════════════════════════════════════════════
// INPUT VALIDATION
// ═══════════════════════════════════════════════════════════════
describe('Input Validation — POST /api/orders', () => {
  test('rejects missing required field (item) → 400', async () => {
    const res = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${tokenA}`)
      .send({ quantity: 5 }); // missing "item"

    expect(res.status).toBe(400);
  });

  test('rejects string in integer field (quantity) → 400', async () => {
    const res = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${tokenA}`)
      .send({ item: 'Widget', quantity: 'not-a-number' });

    expect(res.status).toBe(400);
  });

  test('rejects negative quantity → 400', async () => {
    const res = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${tokenA}`)
      .send({ item: 'Widget', quantity: -5 });

    expect(res.status).toBe(400);
  });

  test('rejects excessively long string field → 400', async () => {
    const res = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${tokenA}`)
      .send({ item: 'A'.repeat(10000), quantity: 1 });

    expect(res.status).toBe(400);
  });

  test('SQL injection payload returns 400, never 500', async () => {
    const res = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${tokenA}`)
      .send({ item: "'; DROP TABLE orders; --", quantity: 1 });

    // 400 = input rejected. 200 = parameterized query handled it safely.
    // 500 = UNACCEPTABLE — means the payload reached the query layer unparameterized.
    expect(res.status).not.toBe(500);
    expect([200, 400]).toContain(res.status);
  });

  test('NoSQL operator injection in JSON body returns 400, not data leak', async () => {
    const res = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${tokenA}`)
      .send({ item: { $gt: '' }, quantity: 1 });

    expect(res.status).toBe(400);
  });

  test('empty body on POST returns 400', async () => {
    const res = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${tokenA}`)
      .send({});

    expect(res.status).toBe(400);
  });
});

// ═══════════════════════════════════════════════════════════════
// MASS ASSIGNMENT
// ═══════════════════════════════════════════════════════════════
describe('Mass Assignment — POST/PUT /api/orders', () => {
  test('cannot set isAdmin via POST body', async () => {
    const res = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${tokenA}`)
      .send({ item: 'Widget', quantity: 1, isAdmin: true });

    // Request may succeed (field stripped) or fail (validation rejects unknown fields)
    if (res.status === 201 || res.status === 200) {
      expect(res.body.isAdmin).toBeUndefined();
    }
  });

  test('cannot override userId to impersonate another user', async () => {
    const res = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${tokenA}`)
      .send({ item: 'Widget', quantity: 1, userId: userB.id });

    if (res.status === 201 || res.status === 200) {
      // The order must belong to User A (the authenticated user), not User B
      expect(res.body.userId).toBe(userA.id);
    }
  });

  test('cannot set role via PUT body', async () => {
    const res = await request(app)
      .put(`/api/orders/${orderA.id}`)
      .set('Authorization', `Bearer ${tokenA}`)
      .send({ item: 'Updated Widget', role: 'admin' });

    if (res.status === 200) {
      expect(res.body.role).toBeUndefined();
    }
  });

  test('cannot override server-generated timestamps', async () => {
    const fakeDate = '2000-01-01T00:00:00.000Z';

    const res = await request(app)
      .put(`/api/orders/${orderA.id}`)
      .set('Authorization', `Bearer ${tokenA}`)
      .send({ item: 'Updated', createdAt: fakeDate, updatedAt: fakeDate });

    if (res.status === 200) {
      expect(res.body.createdAt).not.toBe(fakeDate);
    }
  });

  test('unknown fields in body are stripped, not persisted', async () => {
    const res = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${tokenA}`)
      .send({ item: 'Widget', quantity: 1, _internal: true, __proto__: { admin: true } });

    if (res.status === 201 || res.status === 200) {
      expect(res.body._internal).toBeUndefined();
      expect(res.body.__proto__).toBeUndefined();
    }
  });
});

// ═══════════════════════════════════════════════════════════════
// RATE LIMITING (Auth Endpoints)
// ═══════════════════════════════════════════════════════════════
describe('Rate Limiting — POST /auth/login', () => {
  test('returns 429 after exceeding login attempt limit', async () => {
    const attempts = 15; // Assumes limit is < 15 per window
    const results = [];

    for (let i = 0; i < attempts; i++) {
      const res = await request(app)
        .post('/auth/login')
        .send({ email: 'alice@test.com', password: 'wrong-password' });
      results.push(res.status);
    }

    // At least one response should be 429 (rate limited)
    expect(results).toContain(429);

    // All responses before 429 should be 401 (wrong password), never 500
    results.forEach((status) => {
      expect([401, 429]).toContain(status);
    });
  });

  test('429 response includes Retry-After header', async () => {
    const attempts = 15;
    let rateLimitedResponse = null;

    for (let i = 0; i < attempts; i++) {
      const res = await request(app)
        .post('/auth/login')
        .send({ email: 'alice@test.com', password: 'wrong-password' });

      if (res.status === 429) {
        rateLimitedResponse = res;
        break;
      }
    }

    if (rateLimitedResponse) {
      expect(rateLimitedResponse.headers['retry-after']).toBeDefined();
    }
  });
});

4.2 pytest + httpx — Python (Secondary)

# tests/security/test_orders_security.py

import pytest
import httpx
import jwt
import time

BASE_URL = "http://localhost:3000/api"
JWT_SECRET = "test-secret"
JWT_ALGORITHM = "HS256"


# ─── Fixtures ──────────────────────────────────────────────────
@pytest.fixture(scope="module")
def test_users(setup_database):
    """Create two isolated users with their own orders."""
    user_a = create_user(email="alice@test.com", roles=["user"])
    user_b = create_user(email="bob@test.com", roles=["user"])
    admin = create_user(email="admin@test.com", roles=["admin"])

    order_a = create_order(user_id=user_a["id"], item="Widget", quantity=3)
    order_b = create_order(user_id=user_b["id"], item="Gadget", quantity=1)

    return {
        "user_a": user_a,
        "user_b": user_b,
        "admin": admin,
        "order_a": order_a,
        "order_b": order_b,
        "token_a": make_token(user_a),
        "token_b": make_token(user_b),
        "admin_token": make_token(admin),
    }


def make_token(user, **overrides):
    payload = {
        "sub": user["id"],
        "email": user["email"],
        "roles": user.get("roles", ["user"]),
        "iss": "test-auth-service",
        "aud": "test-api",
        "exp": int(time.time()) + 900,
        **overrides,
    }
    return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)


def make_expired_token(user):
    return make_token(user, exp=int(time.time()) - 60)


def auth_header(token):
    return {"Authorization": f"Bearer {token}"}


# ─── Authentication Tests ──────────────────────────────────────
class TestAuthentication:
    """OWASP API2:2023 — Broken Authentication"""

    def test_no_token_returns_401(self, test_users):
        r = httpx.get(f"{BASE_URL}/orders/{test_users['order_a']['id']}")
        assert r.status_code == 401

    def test_empty_bearer_returns_401(self, test_users):
        r = httpx.get(
            f"{BASE_URL}/orders/{test_users['order_a']['id']}",
            headers={"Authorization": "Bearer "},
        )
        assert r.status_code == 401

    def test_malformed_token_returns_401(self, test_users):
        r = httpx.get(
            f"{BASE_URL}/orders/{test_users['order_a']['id']}",
            headers={"Authorization": "Bearer not.valid.jwt"},
        )
        assert r.status_code == 401

    def test_expired_token_returns_401(self, test_users):
        expired = make_expired_token(test_users["user_a"])
        r = httpx.get(
            f"{BASE_URL}/orders/{test_users['order_a']['id']}",
            headers=auth_header(expired),
        )
        assert r.status_code == 401

    def test_wrong_secret_returns_401(self, test_users):
        bad_token = jwt.encode(
            {"sub": test_users["user_a"]["id"], "roles": ["user"]},
            "completely-wrong-secret",
            algorithm=JWT_ALGORITHM,
        )
        r = httpx.get(
            f"{BASE_URL}/orders/{test_users['order_a']['id']}",
            headers=auth_header(bad_token),
        )
        assert r.status_code == 401


# ─── BOLA Tests ────────────────────────────────────────────────
class TestBOLA:
    """OWASP API1:2023 — Broken Object Level Authorization"""

    def test_user_a_cannot_get_user_b_order(self, test_users):
        r = httpx.get(
            f"{BASE_URL}/orders/{test_users['order_b']['id']}",
            headers=auth_header(test_users["token_a"]),
        )
        assert r.status_code in (403, 404)
        assert "item" not in r.json()

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
20
Forks
1
Last commit
Sep 2026
Advanced
Item type
skill
Key
security-test-generator
Source
github.com/apisec-inc/apisec-skills