Test JWT token validation, session security, IDOR vulnerabilities, brute force protection, and MFA enforcement in NestJS APIs.
Authentication (who are you?) and authorization (what can you do?) failures are consistently in the OWASP Top 10. Authentication failures include weak JWT implementations and inadequate brute force protection. Authorization failures — particularly IDOR — allow authenticated users to access resources they should not see.
This lesson builds a systematic test suite covering JWT security, IDOR detection, rate limiting, and MFA bypass prevention for NestJS.
JWT vulnerabilities are well-catalogued. Test for all of them:
1describe('JWT Security', () => {
2 it('rejects tokens with alg:none (algorithm confusion)', async () => {
3 const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
4 const payload = Buffer.from(JSON.stringify({ sub: 'admin', role: 'admin', exp: 9999999999 })).toString('base64url');
5 const noneToken = `${header}.${payload}.`;
6
7 const res = await request(app.getHttpServer())
8 .get('/api/user/profile')
9 .set('Authorization', `Bearer ${noneToken}`);
10
11 expect(res.status).toBe(401);
12 });
13
14 it('rejects expired tokens', async () => {
15 const expiredToken = jwtService.sign(
16 { sub: 'user-123', email: '[email protected]' },
17 { expiresIn: '-1s' }
18 );
19 const res = await request(app.getHttpServer())
20 .get('/api/user/profile')
21 .set('Authorization', `Bearer ${expiredToken}`);
22 expect(res.status).toBe(401);
23 expect(res.body.message).toMatch(/expired/i);
24 });
25
26 it('rejects tokens signed with wrong secret', async () => {
27 const wrongKeyToken = new JwtService({ secret: 'wrong-secret-key' })
28 .sign({ sub: 'user-123' });
29 const res = await request(app.getHttpServer())
30 .get('/api/user/profile')
31 .set('Authorization', `Bearer ${wrongKeyToken}`);
32 expect(res.status).toBe(401);
33 });
34
35 it('rejects tampered token payload', async () => {
36 const token = jwtService.sign({ sub: 'user-123', role: 'user' });
37 const parts = token.split('.');
38 const tamperedPayload = Buffer.from(
39 JSON.stringify({ sub: 'user-123', role: 'admin' })
40 ).toString('base64url');
41 const tamperedToken = `${parts[0]}.${tamperedPayload}.${parts[2]}`;
42 const res = await request(app.getHttpServer())
43 .get('/api/admin/users')
44 .set('Authorization', `Bearer ${tamperedToken}`);
45 expect(res.status).toBe(401);
46 });
47});IDOR occurs when a user can access another user's resource by guessing or enumerating IDs.
1describe('IDOR Prevention', () => {
2 let userAToken: string;
3 let userBToken: string;
4 let userBOrderId: string;
5
6 beforeAll(async () => {
7 userAToken = await authenticateUser('[email protected]', 'Pass123!');
8 userBToken = await authenticateUser('[email protected]', 'Pass123!');
9
10 const orderRes = await request(app.getHttpServer())
11 .post('/orders')
12 .set('Authorization', `Bearer ${userBToken}`)
13 .send({ courseId: 'course-123' });
14 userBOrderId = orderRes.body.id;
15 });
16
17 it('user A cannot read user B order', async () => {
18 const res = await request(app.getHttpServer())
19 .get(`/orders/${userBOrderId}`)
20 .set('Authorization', `Bearer ${userAToken}`);
21 expect(res.status).toBe(403);
22 });
23
24 it('user A cannot update user B profile', async () => {
25 const res = await request(app.getHttpServer())
26 .patch('/users/userb-id')
27 .set('Authorization', `Bearer ${userAToken}`)
28 .send({ name: 'hacked' });
29 expect(res.status).toBe(403);
30 });
31
32 it('sequential ID enumeration is blocked (or IDs are non-sequential)', async () => {
33 const res = await request(app.getHttpServer())
34 .get('/orders/1') // integer ID attempt
35 .set('Authorization', `Bearer ${userAToken}`);
36 expect([403, 404]).toContain(res.status);
37 });
38});Rate limiting on authentication endpoints prevents credential stuffing attacks.
1describe('Brute Force Protection', () => {
2 it('blocks after N failed login attempts', async () => {
3 const email = '[email protected]';
4 const wrongPassword = 'WrongPassword!';
5
6 // Make N+1 failed attempts in parallel
7 const attempts = Array.from({ length: 10 }, () =>
8 request(app.getHttpServer())
9 .post('/auth/login')
10 .send({ email, password: wrongPassword })
11 );
12 await Promise.all(attempts);
13
14 // Next attempt should be rate-limited
15 const res = await request(app.getHttpServer())
16 .post('/auth/login')
17 .send({ email, password: wrongPassword });
18
19 expect([429, 403]).toContain(res.status);
20 if (res.status === 429) {
21 expect(res.headers['retry-after']).toBeDefined();
22 }
23 });
24
25 it('rate limits by IP to prevent distributed attacks', async () => {
26 // Different emails from same IP should also hit the limit
27 for (let i = 0; i < 10; i++) {
28 await request(app.getHttpServer())
29 .post('/auth/login')
30 .send({ email: `victim${i}@test.com`, password: 'wrong' });
31 }
32 const res = await request(app.getHttpServer())
33 .post('/auth/login')
34 .send({ email: '[email protected]', password: 'wrong' });
35 expect(res.status).toBe(429);
36 });
37});MFA-enabled users must complete both authentication factors.
1describe('MFA Enforcement', () => {
2 it('step-1 token cannot access protected resources', async () => {
3 const loginRes = await request(app.getHttpServer())
4 .post('/auth/login')
5 .send({ email: '[email protected]', password: 'Pass123!' });
6
7 expect(loginRes.body.requiresMfa).toBe(true);
8 const step1Token = loginRes.body.partialToken;
9
10 const profileRes = await request(app.getHttpServer())
11 .get('/user/profile')
12 .set('Authorization', `Bearer ${step1Token}`);
13
14 expect([401, 403]).toContain(profileRes.status);
15 });
16
17 it('MFA requires valid TOTP code', async () => {
18 const loginRes = await request(app.getHttpServer())
19 .post('/auth/login')
20 .send({ email: '[email protected]', password: 'Pass123!' });
21 const step1Token = loginRes.body.partialToken;
22
23 const wrongMfaRes = await request(app.getHttpServer())
24 .post('/auth/mfa/verify')
25 .set('Authorization', `Bearer ${step1Token}`)
26 .send({ code: '000000' });
27
28 expect(wrongMfaRes.status).toBe(401);
29 });
30
31 it('disabling MFA requires re-authentication', async () => {
32 const res = await request(app.getHttpServer())
33 .delete('/auth/mfa')
34 .set('Authorization', `Bearer ${mfaUserToken}`);
35 expect(res.status).toBe(400);
36 expect(res.body.message).toMatch(/password|confirm/i);
37 });
38});Verify Helmet is configured correctly on all responses.
1describe('Security Response Headers', () => {
2 it('X-Content-Type-Options: nosniff', async () => {
3 const res = await request(app.getHttpServer()).get('/api/health');
4 expect(res.headers['x-content-type-options']).toBe('nosniff');
5 });
6
7 it('X-Frame-Options: DENY', async () => {
8 const res = await request(app.getHttpServer()).get('/api/health');
9 expect(res.headers['x-frame-options']).toBe('DENY');
10 });
11
12 it('does not expose server version', async () => {
13 const res = await request(app.getHttpServer()).get('/api/health');
14 expect(res.headers['x-powered-by']).toBeUndefined();
15 expect(res.headers['server']).toBeUndefined();
16 });
17
18 it('Referrer-Policy restricts referrer information', async () => {
19 const res = await request(app.getHttpServer()).get('/api/health');
20 expect(res.headers['referrer-policy']).toMatch(/strict-origin|no-referrer/);
21 });
22});
23
24// NestJS Helmet configuration:
25// import helmet from 'helmet';
26// app.use(helmet({
27// referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
28// frameguard: { action: 'deny' },
29// }));
30// app.set('x-powered-by', false);Complete Jest/Supertest authentication security test suite covering JWT attacks, IDOR, rate limiting, and MFA bypass.
1// apps/api/src/security/__tests__/auth-security.spec.ts
2import { Test, TestingModule } from '@nestjs/testing';
3import { INestApplication, ValidationPipe } from '@nestjs/common';
4import * as request from 'supertest';
5import { JwtService } from '@nestjs/jwt';
6import { AppModule } from '../../app.module';
7
8describe('Authentication Security Test Suite', () => {
9 let app: INestApplication;
10 let jwtService: JwtService;
11 let userAToken: string;
12 let userBToken: string;
13 let userBEnrollmentId: string;
14
15 beforeAll(async () => {
16 const module: TestingModule = await Test.createTestingModule({
17 imports: [AppModule],
18 }).compile();
19 app = module.createNestApplication();
20 app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
21 await app.init();
22 jwtService = module.get(JwtService);
23
24 const loginA = await request(app.getHttpServer())
25 .post('/auth/login')
26 .send({ email: '[email protected]', password: 'TestPass123!' });
27 userAToken = loginA.body.accessToken;
28
29 const loginB = await request(app.getHttpServer())
30 .post('/auth/login')
31 .send({ email: '[email protected]', password: 'TestPass123!' });
32 userBToken = loginB.body.accessToken;
33
34 const enrollRes = await request(app.getHttpServer())
35 .post('/progress/enroll')
36 .set('Authorization', 'Bearer ' + userBToken)
37 .send({ courseId: 'course-typescript-fundamentals' });
38 userBEnrollmentId = enrollRes.body.id;
39 });
40
41 afterAll(() => app.close());
42
43 describe('JWT token security', () => {
44 it('rejects alg:none tokens', async () => {
45 const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
46 const payload = Buffer.from(JSON.stringify({ sub: 'any-user', exp: 9999999999 })).toString('base64url');
47 const noneToken = header + '.' + payload + '.';
48 const res = await request(app.getHttpServer())
49 .get('/user/profile')
50 .set('Authorization', 'Bearer ' + noneToken);
51 expect(res.status).toBe(401);
52 });
53
54 it('rejects expired tokens', async () => {
55 const expired = jwtService.sign({ sub: 'user-123' }, { expiresIn: '-1s' });
56 const res = await request(app.getHttpServer())
57 .get('/user/profile')
58 .set('Authorization', 'Bearer ' + expired);
59 expect(res.status).toBe(401);
60 });
61
62 it('rejects tampered payload', async () => {
63 const parts = userAToken.split('.');
64 const tampered = Buffer.from(JSON.stringify({ sub: 'admin', role: 'admin' })).toString('base64url');
65 const tamperedToken = parts[0] + '.' + tampered + '.' + parts[2];
66 const res = await request(app.getHttpServer())
67 .get('/admin/users')
68 .set('Authorization', 'Bearer ' + tamperedToken);
69 expect(res.status).toBe(401);
70 });
71 });
72
73 describe('IDOR prevention', () => {
74 it('user A cannot read user B enrollment', async () => {
75 const res = await request(app.getHttpServer())
76 .get('/progress/' + userBEnrollmentId)
77 .set('Authorization', 'Bearer ' + userAToken);
78 expect(res.status).toBe(403);
79 });
80
81 it('user A cannot update user B profile', async () => {
82 const userBId = jwtService.decode(userBToken)['sub'];
83 const res = await request(app.getHttpServer())
84 .patch('/users/' + userBId)
85 .set('Authorization', 'Bearer ' + userAToken)
86 .send({ name: 'hacked by A' });
87 expect(res.status).toBe(403);
88 });
89 });
90
91 describe('rate limiting on auth', () => {
92 it('blocks after repeated failed logins', async () => {
93 const attempts = Array.from({ length: 10 }, () =>
94 request(app.getHttpServer())
95 .post('/auth/login')
96 .send({ email: '[email protected]', password: 'WrongPass!' })
97 );
98 await Promise.all(attempts);
99 const blocked = await request(app.getHttpServer())
100 .post('/auth/login')
101 .send({ email: '[email protected]', password: 'WrongPass!' });
102 expect([429, 401]).toContain(blocked.status);
103 });
104 });
105
106 describe('security headers', () => {
107 it('sets all required security headers', async () => {
108 const res = await request(app.getHttpServer()).get('/api/health');
109 expect(res.headers['x-content-type-options']).toBe('nosniff');
110 expect(res.headers['x-frame-options']).toBe('DENY');
111 expect(res.headers['x-powered-by']).toBeUndefined();
112 });
113 });
114});This test suite covers authentication and authorization attack vectors. JWT tests verify alg:none rejection, expiry enforcement, and payload tampering. IDOR tests use two real test users to verify cross-user resource access is blocked with 403. Rate limiting uses Promise.all to send 10 concurrent failed requests. Security headers verify Helmet is correctly configured. All tests use real HTTP requests against the full NestJS application.
The next lesson covers CORS misconfiguration testing and rate limiting — verifying that Access-Control-Allow-Origin does not allow untrusted origins to make credentialed cross-origin requests.
Your progress is saved in this browser, on this device only.