Security Unit Tests: Writing Tests for Auth, RBAC, Rate Limiting, and Input Validation

Security Unit Tests: Writing Tests for Auth, RBAC, Rate Limiting, and Input Validation

Security controls fail silently. A broken authentication check doesn't throw an exception—it returns 200 where it should return 403. A rate limiter with an off-by-one error doesn't log a warning—it just lets the attack through. And because security failures are often hard to observe during normal operation, they tend to survive undetected until someone finds them in production.

Unit tests are the best early-warning system for security regressions. They run on every commit, they're fast, and they're explicit about the exact condition being tested. This guide covers writing security-focused unit tests for the controls that matter most: JWT validation, RBAC checks, rate limiting, and input validation.

Why Security Controls Need Their Own Tests

Application security teams spend significant effort on penetration testing and security scanning. These are valuable, but they have a structural weakness: they test the system at a point in time, not continuously. A developer who refactors the JWT validation function on Tuesday doesn't know their change broke token expiry enforcement until the next pentest in three months.

Unit tests for security controls change this. They assert specific properties—"an expired token must be rejected," "a user without the admin role must get 403"—and they run on every push. Security regressions are caught in minutes, not months.

Security unit tests also serve as executable documentation. A test named test_expired_jwt_returns_401 tells the next developer exactly what the system is supposed to do. This matters when that developer is refactoring authentication code under time pressure at 11pm.

Testing JWT Validation

JWT validation has several independent properties that must each be tested separately. Testing them together in a single integration test means a refactor that breaks token expiry might still pass if the signature check works.

What to Test

  1. Valid token is accepted
  2. Expired token is rejected (even if signature is valid)
  3. Token with future nbf (not before) is rejected
  4. Token with invalid signature is rejected
  5. Token signed with wrong algorithm is rejected (algorithm confusion attack)
  6. Token missing required claims (sub, iss, aud) is rejected
  7. Token with wrong iss is rejected
  8. Token with wrong aud is rejected
  9. none algorithm token is rejected (critical: many early JWT libraries accepted this)

Python (pytest + PyJWT)

# tests/test_jwt_validation.py
import time
import pytest
import jwt
from unittest.mock import patch
from myapp.auth import validate_token, TokenValidationError

SECRET = "test-secret-key"
ISSUER = "https://auth.myapp.com"
AUDIENCE = "myapp-api"


def make_token(
    payload_overrides=None,
    secret=SECRET,
    algorithm="HS256",
    headers=None
):
    now = int(time.time())
    base_payload = {
        "sub": "user-123",
        "iss": ISSUER,
        "aud": AUDIENCE,
        "iat": now,
        "exp": now + 3600,
    }
    if payload_overrides:
        base_payload.update(payload_overrides)
    
    return jwt.encode(base_payload, secret, algorithm=algorithm, headers=headers)


class TestJWTValidation:

    def test_valid_token_returns_claims(self):
        token = make_token()
        claims = validate_token(token)
        assert claims["sub"] == "user-123"

    def test_expired_token_raises_error(self):
        token = make_token(payload_overrides={"exp": int(time.time()) - 1})
        with pytest.raises(TokenValidationError, match="expired"):
            validate_token(token)

    def test_not_yet_valid_token_raises_error(self):
        token = make_token(payload_overrides={"nbf": int(time.time()) + 3600})
        with pytest.raises(TokenValidationError, match="not yet valid"):
            validate_token(token)

    def test_invalid_signature_raises_error(self):
        token = make_token(secret="wrong-secret")
        with pytest.raises(TokenValidationError, match="signature"):
            validate_token(token)

    def test_wrong_algorithm_raises_error(self):
        # RS256 token presented to HS256 validator
        # This tests that algorithm is validated, not just the signature
        token = make_token(headers={"alg": "RS256"})
        with pytest.raises(TokenValidationError):
            validate_token(token)

    def test_none_algorithm_rejected(self):
        # "alg: none" attack — token without a signature
        # Construct manually since jwt library won't help here
        import base64, json
        header = base64.urlsafe_b64encode(
            json.dumps({"alg": "none", "typ": "JWT"}).encode()
        ).rstrip(b'=').decode()
        payload = base64.urlsafe_b64encode(
            json.dumps({"sub": "admin", "exp": int(time.time()) + 3600}).encode()
        ).rstrip(b'=').decode()
        token = f"{header}.{payload}."  # Empty signature
        
        with pytest.raises(TokenValidationError):
            validate_token(token)

    def test_wrong_issuer_raises_error(self):
        token = make_token(payload_overrides={"iss": "https://evil.com"})
        with pytest.raises(TokenValidationError, match="issuer"):
            validate_token(token)

    def test_wrong_audience_raises_error(self):
        token = make_token(payload_overrides={"aud": "different-service"})
        with pytest.raises(TokenValidationError, match="audience"):
            validate_token(token)

    def test_missing_subject_raises_error(self):
        token = make_token(payload_overrides={"sub": None})
        with pytest.raises(TokenValidationError, match="sub"):
            validate_token(token)

Go (testing package)

// auth/jwt_test.go
package auth_test

import (
    "testing"
    "time"

    "github.com/golang-jwt/jwt/v5"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
    "myapp/auth"
)

const testSecret = "test-secret-key-32-bytes-minimum!!"

func makeToken(t *testing.T, claims jwt.MapClaims, secret string) string {
    t.Helper()
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    signed, err := token.SignedString([]byte(secret))
    require.NoError(t, err)
    return signed
}

func validClaims() jwt.MapClaims {
    return jwt.MapClaims{
        "sub": "user-123",
        "iss": "https://auth.myapp.com",
        "aud": jwt.ClaimStrings{"myapp-api"},
        "iat": time.Now().Unix(),
        "exp": time.Now().Add(time.Hour).Unix(),
    }
}

func TestValidToken(t *testing.T) {
    token := makeToken(t, validClaims(), testSecret)
    claims, err := auth.ValidateToken(token)
    require.NoError(t, err)
    assert.Equal(t, "user-123", claims.Subject)
}

func TestExpiredToken(t *testing.T) {
    claims := validClaims()
    claims["exp"] = time.Now().Add(-time.Second).Unix()
    token := makeToken(t, claims, testSecret)
    _, err := auth.ValidateToken(token)
    require.Error(t, err)
    assert.Contains(t, err.Error(), "expired")
}

func TestInvalidSignature(t *testing.T) {
    token := makeToken(t, validClaims(), "wrong-secret")
    _, err := auth.ValidateToken(token)
    require.Error(t, err)
    assert.Contains(t, err.Error(), "signature")
}

func TestWrongIssuer(t *testing.T) {
    claims := validClaims()
    claims["iss"] = "https://attacker.com"
    token := makeToken(t, claims, testSecret)
    _, err := auth.ValidateToken(token)
    require.Error(t, err)
}

Testing RBAC and Permission Checks

Authorization bugs are often IDOR (Insecure Direct Object Reference) or privilege escalation. Tests must explicitly verify that resource access is scoped to the correct user and that role checks are enforced.

Testing Horizontal Privilege Escalation (IDOR)

# tests/test_authorization.py
import pytest
from myapp.documents import get_document, update_document
from myapp.auth import User

class TestDocumentAuthorization:

    def test_owner_can_read_document(self, db):
        owner = User(id="user-1", role="user")
        doc = db.create_document(owner_id="user-1", content="secret")
        
        result = get_document(doc.id, requesting_user=owner)
        assert result.id == doc.id

    def test_other_user_cannot_read_document(self, db):
        owner = User(id="user-1", role="user")
        attacker = User(id="user-2", role="user")
        doc = db.create_document(owner_id="user-1", content="secret")
        
        with pytest.raises(PermissionError):
            get_document(doc.id, requesting_user=attacker)

    def test_admin_can_read_any_document(self, db):
        owner = User(id="user-1", role="user")
        admin = User(id="admin-1", role="admin")
        doc = db.create_document(owner_id="user-1", content="sensitive")
        
        result = get_document(doc.id, requesting_user=admin)
        assert result.id == doc.id

    def test_user_cannot_update_other_users_document(self, db):
        owner = User(id="user-1", role="user")
        attacker = User(id="user-2", role="user")
        doc = db.create_document(owner_id="user-1", content="original")
        
        with pytest.raises(PermissionError):
            update_document(doc.id, content="tampered", requesting_user=attacker)


class TestAdminEndpoints:
    """Vertical privilege escalation: regular users accessing admin functions."""

    def test_admin_can_list_all_users(self, client, admin_token):
        resp = client.get("/admin/users", 
                         headers={"Authorization": f"Bearer {admin_token}"})
        assert resp.status_code == 200

    def test_user_cannot_list_all_users(self, client, user_token):
        resp = client.get("/admin/users",
                         headers={"Authorization": f"Bearer {user_token}"})
        assert resp.status_code == 403

    def test_unauthenticated_cannot_list_users(self, client):
        resp = client.get("/admin/users")
        assert resp.status_code == 401

    def test_user_cannot_delete_other_account(self, client, user_token):
        resp = client.delete("/admin/users/other-user-id",
                            headers={"Authorization": f"Bearer {user_token}"})
        assert resp.status_code == 403

Node.js (Jest + Supertest)

// tests/auth.test.js
const request = require('supertest');
const app = require('../src/app');
const { generateToken } = require('../src/auth');
const { db } = require('../src/db');

describe('Authorization Tests', () => {
  let userToken, adminToken, documentId;

  beforeAll(async () => {
    userToken = generateToken({ userId: 'user-1', role: 'user' });
    adminToken = generateToken({ userId: 'admin-1', role: 'admin' });
    
    const doc = await db.documents.create({ 
      ownerId: 'user-1', 
      content: 'sensitive data' 
    });
    documentId = doc.id;
  });

  describe('IDOR Protection', () => {
    it('rejects access to another user document', async () => {
      const otherUserToken = generateToken({ userId: 'user-2', role: 'user' });
      
      const res = await request(app)
        .get(`/api/documents/${documentId}`)
        .set('Authorization', `Bearer ${otherUserToken}`);
      
      expect(res.status).toBe(403);
      // IMPORTANT: Verify the response doesn't leak document existence
      // A 404 would reveal that the document ID exists; depends on your security model
    });
  });

  describe('Role-Based Access', () => {
    it('blocks non-admin from admin endpoint', async () => {
      const res = await request(app)
        .get('/api/admin/users')
        .set('Authorization', `Bearer ${userToken}`);
      
      expect(res.status).toBe(403);
    });

    it('allows admin to access admin endpoint', async () => {
      const res = await request(app)
        .get('/api/admin/users')
        .set('Authorization', `Bearer ${adminToken}`);
      
      expect(res.status).toBe(200);
    });
  });
});

Testing Rate Limiting

Rate limiting tests have a timing component that makes them tricky. The key is to test the logic layer directly, not just the HTTP layer, and to use a test-specific clock or counter instead of relying on real time.

# tests/test_rate_limiting.py
import pytest
from unittest.mock import patch
from myapp.rate_limiter import RateLimiter, RateLimitExceeded

class TestRateLimiter:
    """Unit tests for the rate limiter logic layer."""

    def test_requests_within_limit_are_allowed(self):
        limiter = RateLimiter(max_requests=5, window_seconds=60)
        
        for i in range(5):
            assert limiter.check("user-1") is True

    def test_request_exceeding_limit_is_blocked(self):
        limiter = RateLimiter(max_requests=5, window_seconds=60)
        
        for _ in range(5):
            limiter.check("user-1")
        
        with pytest.raises(RateLimitExceeded):
            limiter.check("user-1")

    def test_limits_are_per_user(self):
        limiter = RateLimiter(max_requests=1, window_seconds=60)
        
        # user-1 hits their limit
        limiter.check("user-1")
        with pytest.raises(RateLimitExceeded):
            limiter.check("user-1")
        
        # user-2's limit is unaffected
        assert limiter.check("user-2") is True

    def test_limit_resets_after_window(self):
        limiter = RateLimiter(max_requests=1, window_seconds=60)
        
        with patch('myapp.rate_limiter.time') as mock_time:
            mock_time.time.return_value = 1000.0
            limiter.check("user-1")
            
            # Within window: blocked
            with pytest.raises(RateLimitExceeded):
                limiter.check("user-1")
            
            # After window: allowed again
            mock_time.time.return_value = 1061.0
            assert limiter.check("user-1") is True

    def test_rate_limit_headers_are_set(self, client, user_token):
        """HTTP integration test for rate limit response headers."""
        res = client.get("/api/data",
                        headers={"Authorization": f"Bearer {user_token}"})
        
        assert "X-RateLimit-Limit" in res.headers
        assert "X-RateLimit-Remaining" in res.headers
        assert "X-RateLimit-Reset" in res.headers

    def test_rate_limited_response_is_429(self, client, user_token):
        """Verify HTTP 429 is returned (not 503 or 200)."""
        # Exhaust the rate limit
        rate_limit = 10  # Must match your test config
        for _ in range(rate_limit):
            client.get("/api/data",
                      headers={"Authorization": f"Bearer {user_token}"})
        
        # Next request should be rate limited
        res = client.get("/api/data",
                        headers={"Authorization": f"Bearer {user_token}"})
        
        assert res.status_code == 429
        assert "Retry-After" in res.headers

Testing Input Validation and Injection Prevention

Input validation tests should cover boundary conditions, type confusion, and known injection patterns.

# tests/test_input_validation.py
import pytest
from myapp.api import create_user, search_users

class TestInputValidation:

    # --- SQL Injection ---
    @pytest.mark.parametrize("malicious_input", [
        "'; DROP TABLE users; --",
        "' OR '1'='1",
        "1; SELECT * FROM users WHERE '1'='1",
        "admin'--",
        "1 UNION SELECT null,username,password FROM users--",
    ])
    def test_sql_injection_attempts_are_rejected(self, malicious_input):
        with pytest.raises((ValueError, ValidationError)):
            search_users(query=malicious_input)

    # --- XSS Prevention ---
    @pytest.mark.parametrize("xss_payload", [
        "<script>alert('xss')</script>",
        "<img src=x onerror=alert(1)>",
        "javascript:alert(1)",
        "<svg/onload=alert(1)>",
    ])
    def test_xss_payloads_are_sanitized_in_display_name(self, xss_payload):
        user = create_user(
            email="test@example.com",
            display_name=xss_payload,
            password="Password123!"
        )
        # Output must not contain raw script tags
        assert "<script>" not in user.display_name
        assert "javascript:" not in user.display_name

    # --- Path Traversal ---
    @pytest.mark.parametrize("traversal_path", [
        "../../../etc/passwd",
        "..\\..\\..\\windows\\system32\\config\\sam",
        "%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd",
        "....//....//....//etc/passwd",
    ])
    def test_path_traversal_in_file_download_is_blocked(self, client, user_token, traversal_path):
        res = client.get(
            f"/api/files/{traversal_path}",
            headers={"Authorization": f"Bearer {user_token}"}
        )
        assert res.status_code in (400, 403, 404)
        # Response must not contain system file contents
        assert "root:" not in res.text
        assert "[boot loader]" not in res.text

    # --- Mass Assignment ---
    def test_cannot_set_role_via_registration(self, client):
        res = client.post("/api/users/register", json={
            "email": "test@example.com",
            "password": "Password123!",
            "role": "admin",         # Attempt to escalate via mass assignment
            "is_admin": True,
        })
        assert res.status_code in (200, 201)
        
        user_id = res.json()["id"]
        user = client.get(f"/api/users/{user_id}").json()
        assert user["role"] == "user"  # Must not be "admin"
        assert user.get("is_admin") is not True

    # --- Integer Overflow / Type Confusion ---
    @pytest.mark.parametrize("bad_id", [
        -1,
        0,
        "null",
        "undefined",
        9999999999999999999,
        "1; DROP TABLE users",
        {"$gt": ""},    # MongoDB operator injection
    ])
    def test_invalid_user_ids_are_rejected(self, client, user_token, bad_id):
        res = client.get(
            f"/api/users/{bad_id}",
            headers={"Authorization": f"Bearer {user_token}"}
        )
        assert res.status_code in (400, 404)

Testing for Sensitive Data in Error Responses

class TestErrorResponses:

    def test_authentication_failure_message_is_generic(self, client):
        """Error message must not reveal whether the user exists."""
        
        res_bad_password = client.post("/api/auth/login", json={
            "email": "existing@example.com",
            "password": "wrongpassword"
        })
        res_no_user = client.post("/api/auth/login", json={
            "email": "nonexistent@example.com",
            "password": "anypassword"
        })
        
        # Both must return the same status code
        assert res_bad_password.status_code == res_no_user.status_code == 401
        
        # Both must return the same message (user enumeration prevention)
        assert res_bad_password.json()["message"] == res_no_user.json()["message"]

    def test_server_error_does_not_expose_stack_trace(self, client, user_token):
        """Trigger an internal error and verify no stack trace leaks."""
        # Intentionally malformed request to trigger a 500
        res = client.post(
            "/api/process",
            data="not-json-at-all",
            content_type="application/json",
            headers={"Authorization": f"Bearer {user_token}"}
        )
        
        response_text = res.text
        assert "Traceback" not in response_text
        assert "File \"/" not in response_text
        assert "line " not in response_text
        assert "Exception" not in response_text

CI Integration

Security tests should run on every PR, with failing security tests blocking merge. Here is a GitHub Actions workflow:

# .github/workflows/security-tests.yml
name: Security Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  security-unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install dependencies
        run: pip install -r requirements-dev.txt

      - name: Run security-focused tests
        run: |
          pytest tests/test_jwt_validation.py \
                 tests/test_authorization.py \
                 tests/test_rate_limiting.py \
                 tests/test_input_validation.py \
                 -v \
                 --tb=short \
                 --junitxml=security-test-results.xml \
                 -m "security or auth"

      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: security-test-results
          path: security-test-results.xml

      - name: Publish Test Results
        uses: EnricoMi/publish-unit-test-result-action@v2
        if: always()
        with:
          files: security-test-results.xml
          comment_mode: always
          check_name: "Security Test Results"

Mark security tests with pytest markers for easy filtering:

# conftest.py
import pytest

def pytest_configure(config):
    config.addinivalue_line(
        "markers", "security: mark test as a security control test"
    )
    config.addinivalue_line(
        "markers", "auth: mark test as an authentication/authorization test"
    )
# Apply markers to test classes
@pytest.mark.security
@pytest.mark.auth
class TestJWTValidation:
    ...

Measuring Security Test Coverage

Track which security controls have tests by maintaining a coverage matrix. A simple approach: tag each test with the control it covers and generate a report in CI.

# Each test function name encodes what it tests:
# test_{control}_{condition}_{expected_result}

def test_jwt_expired_token_returns_401(): ...
def test_rbac_user_role_accessing_admin_returns_403(): ...
def test_rate_limiter_11th_request_in_10_limit_window_blocked(): ...
def test_input_sql_injection_in_search_rejected(): ...

A consistent naming convention makes it trivial to answer "do we have a test for X?" with a grep. That question comes up more often than you'd expect—usually when a security researcher files a bug report and you need to determine whether existing tests would have caught it.

Security controls without tests are aspirational. Security controls with tests are verified. The gap between those two things is everything.

Read more

Start now free