Security testing verifies that your application resists known attack vectors. Unlike functional testing (does the feature work?), security testing asks “what happens when someone tries to break this?” Writing security tests alongside features catches vulnerabilities before deployment.
This guide covers writing automated security tests in Jest, checking for the OWASP Top 10, and integrating static analysis tools into your CI pipeline.
Testing Authentication and Authorization
The highest-impact security tests verify that your access controls actually work — not just that they exist:
// auth.test.js
describe('Authentication boundaries', () => {
test('protected routes reject unauthenticated requests', async () => {
const response = await fetch('/api/users/profile');
expect(response.status).toBe(401);
});
test('wrong credentials return 401 not 403', async () => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: '[email protected]', password: 'wrongpassword' }),
});
// 401 = not authenticated; never leak which field is wrong
expect(response.status).toBe(401);
const body = await response.json();
expect(body.error).toBe('Invalid credentials');
expect(body.error).not.toMatch(/password/i); // don't reveal which field failed
});
test('expired tokens are rejected', async () => {
// Generate a token that expired 1 hour ago
const expiredToken = jwt.sign({ id: 1 }, process.env.JWT_SECRET, {
expiresIn: -3600, // already expired
});
const response = await fetch('/api/users/profile', {
headers: { Authorization: `Bearer ${expiredToken}` },
});
expect(response.status).toBe(401);
});
});
describe('Authorization — access control', () => {
let userToken, adminToken;
beforeEach(async () => {
userToken = await loginAs('[email protected]', 'password');
adminToken = await loginAs('[email protected]', 'password');
});
test('regular users cannot access admin endpoints', async () => {
const response = await fetch('/api/admin/users', {
headers: { Authorization: `Bearer ${userToken}` },
});
expect(response.status).toBe(403);
});
test('users cannot access other users data', async () => {
// User 1 tries to read User 2's private data
const response = await fetch('/api/users/2/private', {
headers: { Authorization: `Bearer ${userToken}` }, // user 1's token
});
expect([403, 404]).toContain(response.status); // either is acceptable
});
test('admins can access admin endpoints', async () => {
const response = await fetch('/api/admin/users', {
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(response.status).toBe(200);
});
});
The “no which field failed” test is easy to miss — many authentication endpoints helpfully say “email not found” or “wrong password,” which enables account enumeration. Test that yours doesn’t.
Testing Input Validation
Security tests should include actual attack payloads, not just empty strings:
describe('Input validation — injection prevention', () => {
const xssPayloads = [
'<script>alert("xss")</script>',
'"><script>alert(1)</script>',
"'; DROP TABLE users; --",
'<img src=x onerror=alert(1)>',
'javascript:alert(1)',
];
test.each(xssPayloads)(
'XSS payload %s is rejected or sanitized',
async (payload) => {
const response = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ text: payload }),
});
if (response.status === 200 || response.status === 201) {
const saved = await response.json();
// If stored, it must be escaped — raw script tags must never persist
expect(saved.text).not.toMatch(/<script/i);
expect(saved.text).not.toMatch(/onerror=/i);
}
// Or the endpoint should reject it with 400
}
);
const sqlInjectionPayloads = [
"' OR '1'='1",
"1; DROP TABLE users",
"' UNION SELECT * FROM users --",
];
test.each(sqlInjectionPayloads)(
'SQL injection payload %s does not expose data',
async (payload) => {
const response = await fetch(`/api/users?email=${encodeURIComponent(payload)}`);
// Should either return no results or a 400, never leak data
if (response.status === 200) {
const data = await response.json();
expect(Array.isArray(data) ? data.length : 0).toBe(0);
} else {
expect(response.status).toBe(400);
}
}
);
});
Testing Security Headers
Security headers defend against a range of client-side attacks. One test covers your entire middleware setup:
describe('Security headers', () => {
let response;
beforeAll(async () => {
response = await fetch('http://localhost:3000/');
});
test.each([
['Content-Security-Policy', /default-src/],
['X-Content-Type-Options', 'nosniff'],
['X-Frame-Options', /DENY|SAMEORIGIN/],
['Strict-Transport-Security', /max-age/],
['Referrer-Policy', /strict-origin|no-referrer/],
])('sets %s header', (headerName, expected) => {
const value = response.headers.get(headerName);
expect(value).toBeTruthy();
if (expected instanceof RegExp) {
expect(value).toMatch(expected);
} else {
expect(value).toBe(expected);
}
});
test('does not expose framework/version info', () => {
// X-Powered-By: Express leaks implementation details
expect(response.headers.get('X-Powered-By')).toBeNull();
expect(response.headers.get('Server')).not.toMatch(/express|node|nginx\/.+/i);
});
});
Testing CSRF Protection
CSRF tests verify that state-changing endpoints require a valid token:
describe('CSRF protection', () => {
test('POST without CSRF token is rejected', async () => {
const sessionCookie = await login();
const response = await fetch('/api/users/settings', {
method: 'POST',
headers: {
Cookie: sessionCookie,
'Content-Type': 'application/json',
// Deliberately omit X-CSRF-Token
},
body: JSON.stringify({ email: '[email protected]' }),
});
expect(response.status).toBe(403);
});
test('POST with valid CSRF token succeeds', async () => {
const sessionCookie = await login();
const csrfToken = await getCsrfToken(sessionCookie);
const response = await fetch('/api/users/settings', {
method: 'POST',
headers: {
Cookie: sessionCookie,
'X-CSRF-Token': csrfToken,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: '[email protected]' }),
});
expect(response.status).toBe(200);
});
});
Static Analysis: ESLint Security Plugin
Static analysis catches security patterns before code even runs:
npm install --save-dev eslint-plugin-security eslint-plugin-no-unsanitized
// .eslintrc.json
{
"plugins": ["security", "no-unsanitized"],
"extends": ["plugin:security/recommended"],
"rules": {
"no-unsanitized/method": "error",
"no-unsanitized/property": "error",
"security/detect-eval-with-expression": "error",
"security/detect-non-literal-regexp": "warn",
"security/detect-object-injection": "warn",
"security/detect-possible-timing-attacks": "error"
}
}
eslint-plugin-security flags patterns like eval(), new Function(), non-literal regexes, and object injection via bracket notation. Run it in CI: npx eslint --ext .js,.ts src/.
Dependency Auditing
Your node_modules is your largest attack surface. Audit it regularly:
# Built-in npm audit — free, catches known CVEs
npm audit
npm audit --audit-level=moderate # fail CI on moderate+
npm audit fix # auto-fix where possible
# Snyk — more detailed, can check in CI
npx snyk test
npx snyk test --severity-threshold=high
# Check for outdated packages
npm outdated
Add to CI:
# .github/workflows/security.yml
- name: Dependency audit
run: npm audit --audit-level=high
- name: Check for outdated packages
run: npx npm-check-updates --errorLevel 2 --reject '/^@types/'
Checking for Secrets in Code
Hardcoded API keys and passwords leak into git history permanently. Use detect-secrets or git-secrets to prevent it:
# detect-secrets — Python tool that scans for credential patterns
pip install detect-secrets
detect-secrets scan --all-files > .secrets.baseline
detect-secrets audit .secrets.baseline
# In pre-commit hook
detect-secrets-hook --baseline .secrets.baseline
Or use GitHub’s built-in secret scanning (free for public repos, paid for private).
OWASP Top 10 Coverage Checklist
Map your tests to the OWASP Top 10:
| OWASP Item | Test Type | How |
|---|---|---|
| A01: Broken Access Control | Auth tests | Test IDOR, privilege escalation |
| A02: Cryptographic Failures | HTTPS, cookie flags | Check headers, cookie secure flag |
| A03: Injection | Input validation tests | XSS/SQL payloads in test.each |
| A05: Security Misconfiguration | Headers test | Check all security headers |
| A06: Vulnerable Components | npm audit in CI |
Fail on high/critical CVEs |
| A07: Auth Failures | Auth boundary tests | Expired tokens, wrong credentials |
| A08: Data Integrity Failures | CSRF tests | Token required on mutations |
Items A04 (Insecure Design), A09 (Logging Failures), and A10 (SSRF) require code review or manual testing rather than automated tests.
Summary
- Authentication tests should verify the negative cases: wrong credentials, expired tokens, missing tokens — not just that login works
- Use
test.eachwith real XSS and SQL injection payloads — tests that only check empty strings miss real attacks - One security headers test covers your entire middleware setup — write it once
npm audit --audit-level=highin CI catches known CVEs before deployment- ESLint security plugins catch dangerous patterns (eval, innerHTML, object injection) at lint time
Comments