API Security Testing with Postman: A Practical Guide

API Security Testing with Postman: A Practical Guide

APIs are where most modern application logic lives — and where most security vulnerabilities occur. REST APIs, GraphQL endpoints, and WebSocket connections are all attack surfaces that need explicit security testing.

Postman is a tool most developers and QA engineers already have. Beyond basic functional testing, it's surprisingly capable for security testing: you can write test scripts, chain requests, use environment variables, and automate repetitive checks.

This guide shows you how to use Postman for API security testing — from auth bypass to injection to rate limiting.

Setting Up for Security Testing

Create a Security Testing Environment

In Postman, create a separate environment for security testing with these variables:

base_url: https://api.example.com
admin_token: <your-admin-jwt>
user_token: <your-regular-user-jwt>
another_user_token: <token-for-a-second-test-account>
admin_user_id: 1001
regular_user_id: 1002
another_user_id: 1003

Having multiple user accounts is essential for authorization testing. Create test accounts with different permission levels before you start.

Capture Your API Traffic

The fastest way to map an API's attack surface:

  1. Set Postman as your browser proxy: Settings → Proxy → Custom Proxy → 127.0.0.1:5559
  2. Browse the application normally — Postman captures all API calls
  3. In Postman, use History to review captured requests
  4. Import interesting requests into a security testing collection

Alternatively, if the API has an OpenAPI/Swagger spec, import it directly: Import → Link → paste spec URL. This gives you a complete list of endpoints to test.

Testing Authentication

Test 1: Missing Authentication

For every endpoint, verify that unauthenticated requests are rejected.

// Postman Pre-request Script — remove auth header
pm.request.headers.remove('Authorization');

Or create a duplicate request with the Authorization header removed. The test:

pm.test("Unauthenticated request rejected", function() {
    pm.expect(pm.response.code).to.be.oneOf([401, 403]);
});

Send to every endpoint. Any 200 response without authentication is a vulnerability.

Test 2: JWT Vulnerabilities

If your API uses JWTs, decode the token (jwt.io or Postman's built-in JWT decode):

// In Postman test script — decode and inspect JWT
const token = pm.environment.get('user_token');
const payload = JSON.parse(atob(token.split('.')[1]));
console.log(payload);

// Check for weak algorithm
pm.test("JWT does not use 'none' algorithm", function() {
    const header = JSON.parse(atob(token.split('.')[0]));
    pm.expect(header.alg).to.not.equal('none');
    pm.expect(header.alg).to.not.equal('HS256'); // weak — prefer RS256
});

Algorithm confusion test: Some servers accept alg: none (no signature) or can be tricked into verifying an HMAC signature using the public key as the secret.

Craft a modified JWT:

  1. Take a valid JWT
  2. Change the payload (elevate role to admin)
  3. Change the header alg to none
  4. Remove the signature
  5. Send the request — does it work?
Header: {"alg":"none","typ":"JWT"}
Payload: {"sub":"1002","role":"admin","exp":9999999999}
Signature: (empty)

→ Token: eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMDAyIiwicm9sZSI6ImFkbWluIiwiZXhwIjo5OTk5OTk5OTk5fQ.

Expected: 401 Unauthorized. Fail: request succeeds with elevated privileges.

Test 3: Token Expiry

// Save an old token, wait for it to expire, then test
const expiredToken = 'eyJhbGci...'; // token saved from a previous session

pm.request.headers.upsert({
    key: 'Authorization',
    value: `Bearer ${expiredToken}`
});

Test:

pm.test("Expired token rejected", function() {
    pm.expect(pm.response.code).to.equal(401);
});

Test 4: API Key Handling

If the API uses API keys:

// Test API key in different locations
// Some APIs accept keys in headers, query params, or body — check all
pm.test("API key required in header", function() {
    // Send request without X-API-Key header
    pm.expect(pm.response.code).to.equal(401);
});

Also test:

  • What happens with an invalid key? Does error message reveal key format/length?
  • Is the key visible in server logs? (Send key in URL param — servers log URLs)
  • Is the key rotatable? Does the old key expire after rotation?

Testing Authorization (IDOR and Privilege Escalation)

Test 5: Horizontal IDOR

The most common API vulnerability. User A accessing User B's data.

Create a Postman collection runner that cycles through resource IDs:

// Pre-request Script
const ids = [1001, 1002, 1003, 1004, 1005];
const currentIndex = pm.environment.get('idor_index') || 0;
pm.environment.set('current_id', ids[currentIndex]);
pm.environment.set('idor_index', parseInt(currentIndex) + 1);

Request:

GET {{base_url}}/api/users/{{current_id}}/profile
Authorization: Bearer {{user_token}}  // User 1002's token

Test:

pm.test("Cannot access other users' profiles", function() {
    const currentId = pm.environment.get('current_id');
    const myId = pm.environment.get('regular_user_id');
    
    if (currentId !== myId) {
        // Requesting someone else's profile — should fail
        pm.expect(pm.response.code).to.be.oneOf([403, 404]);
    } else {
        // Own profile — should succeed
        pm.expect(pm.response.code).to.equal(200);
    }
});

Run this collection with iterations set to 5. Any 200 response on a non-owned resource = confirmed IDOR.

Test 6: Vertical Privilege Escalation

Can a regular user access admin-only endpoints?

// Use regular user token against admin endpoints
// These requests should all return 403

const adminEndpoints = [
    '/api/admin/users',
    '/api/admin/settings',
    '/api/admin/reports',
    '/api/users/delete',
];

Create requests for each admin endpoint using the user_token (not admin_token).

pm.test("Regular user cannot access admin endpoint", function() {
    pm.expect(pm.response.code).to.equal(403);
});

Test 7: Mass Assignment

Some APIs expose all model properties in update endpoints, including ones that shouldn't be user-modifiable.

// Normal request
PATCH /api/users/1002
{"name": "New Name", "email": "new@example.com"}

// Attack — add fields the user shouldn't be able to modify
PATCH /api/users/1002
{
    "name": "New Name",
    "email": "new@example.com",
    "role": "admin",
    "credits": 999999,
    "verified": true,
    "subscription": "enterprise"
}

Test:

pm.test("Extra fields ignored or rejected", function() {
    const response = pm.response.json();
    // Verify role didn't change
    pm.expect(response.role).to.not.equal('admin');
    pm.expect(response.credits).to.not.equal(999999);
});

Testing Input Validation and Injection

Test 8: SQL Injection

Create a request variable {{injection_payload}} and test each parameter:

Common payloads to test:

'
''
`
''`
' OR '1'='1
' OR '1'='1'--
' OR '1'='1'/*
) OR ('1'='1
'; DROP TABLE users--
1 UNION SELECT null,null,null--
// Pre-request Script — cycle through injection payloads
const payloads = ["'", "' OR '1'='1", "'; SELECT 1--", "1 UNION SELECT null--"];
const idx = pm.environment.get('inj_index') || 0;
pm.environment.set('injection_payload', payloads[idx]);
pm.environment.set('inj_index', parseInt(idx) + 1);

Request:

GET {{base_url}}/api/products?search={{injection_payload}}

Tests:

pm.test("No SQL error in response", function() {
    const body = pm.response.text();
    const sqlErrors = ['SQL syntax', 'ORA-', 'PostgreSQL', 'mysql_fetch', 
                       'You have an error', 'sqlite3.', 'SQLSTATE'];
    sqlErrors.forEach(err => {
        pm.expect(body).to.not.include(err);
    });
});

pm.test("No unexpected 500 errors on injection attempt", function() {
    pm.expect(pm.response.code).to.not.equal(500);
});

Test 9: NoSQL Injection

If the API uses MongoDB or another NoSQL database:

// Instead of: {"username": "admin", "password": "test"}
// Try:
{
    "username": "admin",
    "password": {"$gt": ""}
}

// Or in URL params:
// ?username=admin&password[$gt]=
pm.test("NoSQL injection rejected", function() {
    pm.expect(pm.response.code).to.not.equal(200);
    // Or verify you didn't get authenticated
    pm.expect(pm.response.json()).to.not.have.property('token');
});

Test 10: Command Injection in File Operations

For any endpoint that takes filenames, paths, or system commands:

filename=test.pdf
filename=test.pdf; whoami
filename=../../../etc/passwd
filename=test%00.pdf
pm.test("Command injection blocked", function() {
    pm.expect(pm.response.code).to.not.equal(200);
    pm.expect(pm.response.text()).to.not.include('root:');
    pm.expect(pm.response.text()).to.not.include('/bin/bash');
});

Testing Rate Limiting

Test 11: Brute Force Protection

// Run in a Collection Runner with 50 iterations
pm.test("Rate limiting kicks in after repeated failures", function() {
    const iteration = pm.info.iteration;
    if (iteration > 10) {
        // After 10 attempts, expect 429 Too Many Requests
        pm.expect(pm.response.code).to.equal(429);
    }
});

Check response headers for rate limit info:

pm.test("Rate limit headers present", function() {
    pm.expect(pm.response.headers.get('X-RateLimit-Limit')).to.exist;
    pm.expect(pm.response.headers.get('X-RateLimit-Remaining')).to.exist;
    pm.expect(pm.response.headers.get('Retry-After')).to.exist;
});

Test 12: Resource Exhaustion

Test what happens with unusually large inputs:

// Pre-request Script — send large payload
const largeString = 'A'.repeat(100000);
pm.request.body.update({
    mode: 'raw',
    raw: JSON.stringify({search: largeString})
});

Expected: 400 Bad Request or 413 Payload Too Large. Fail: server hangs, returns 500, or processes the request.

Testing Information Disclosure

Test 13: Verbose Error Messages

Trigger errors intentionally:

// Wrong types
GET /api/products/abc  (where integer expected)
GET /api/products/-1
GET /api/products/99999999999999

// Missing required fields
POST /api/orders {}

// Malformed JSON
POST /api/orders
Content-Type: application/json
Body: {invalid json}

Tests:

pm.test("No stack traces in error responses", function() {
    const body = pm.response.text();
    pm.expect(body).to.not.include('at Object.');      // Node.js stack trace
    pm.expect(body).to.not.include('Traceback');       // Python
    pm.expect(body).to.not.include('Exception in');    // Java
    pm.expect(body).to.not.include('/app/server.js');  // File paths
});

pm.test("Error response uses standard format", function() {
    const response = pm.response.json();
    // Should have error message but not internal details
    pm.expect(response).to.have.property('error');
    pm.expect(response).to.not.have.property('stack');
    pm.expect(response).to.not.have.property('query'); // No raw SQL
});

Test 14: Sensitive Data in Responses

pm.test("Password not returned in user object", function() {
    const response = pm.response.json();
    pm.expect(response).to.not.have.property('password');
    pm.expect(response).to.not.have.property('password_hash');
    pm.expect(response).to.not.have.property('salt');
});

pm.test("Full credit card number not returned", function() {
    const body = pm.response.text();
    // Credit card pattern
    pm.expect(body).to.not.match(/\b\d{16}\b/);
});

Organizing Security Tests in Postman

Create a Security folder in your Postman collection with subfolders:

Security/
├── Authentication/
│   ├── Unauthenticated access
│   ├── Expired token
│   ├── JWT algorithm none
│   └── Invalid token formats
├── Authorization/
│   ├── IDOR - user resources
│   ├── IDOR - admin endpoints  
│   └── Mass assignment
├── Injection/
│   ├── SQL injection scan
│   ├── NoSQL injection
│   └── Command injection
├── Rate Limiting/
│   ├── Login brute force
│   └── API abuse
└── Information Disclosure/
    ├── Error messages
    └── Sensitive data in responses

Running in CI with Newman

Export your Postman collection and run it in CI with Newman:

npm install -g newman

# Run security test collection
newman run security-tests.postman_collection.json \
  --environment security.postman_environment.json \
  --reporters cli,junit \
  --reporter-junit-export security-results.xml

In GitHub Actions:

- name: API Security Tests
  run: |
    newman run collections/security-tests.json \
      --environment environments/staging.json \
      --bail

The --bail flag stops the run on first failure — useful when a security failure should block deployment.

Beyond Postman

Postman is excellent for structured security test cases. Pair it with:

  • Burp Suite for intercepting and fuzzing ad-hoc during exploratory testing
  • OWASP ZAP for automated scanning in CI
  • sqlmap for thorough SQL injection testing

For our full penetration testing methodology, see the web app penetration testing guide. For integrating security scans into your CI pipeline, see automated security testing in CI.

HelpMeTest can complement Postman security tests by running authentication and authorization scenarios continuously against your staging environment — catching regressions as soon as they're deployed.

Read more

Start now free