Testing your auth system: unit tests, integration tests, and security-focused test cases

Authentication code is uniquely important to test correctly, and uniquely easy to test incorrectly. The most common mistake is mocking the authentication layer in tests — returning a hardcoded user object and bypassing actual token validation. This gives you tests that pass with zero coverage of the code that actually matters. When a JWT validation bug ships to production, the tests that were supposed to catch it were testing a mock, not the real path.

This post covers how to structure auth tests at each layer: unit tests for pure logic, integration tests against a real auth stack, and a checklist of security-specific edge cases that should fail loudly in your test suite.

What not to mock

The authentication middleware and the JWT validation library are the two most important things not to mock. These are the exact places where security bugs live. Test them against real tokens, real keys, and real edge cases.

What you can mock: external HTTP calls (the JWKS endpoint, the introspection endpoint), email delivery, clock time (for testing expiry). The distinction is: mock the I/O, not the security logic.

// BAD: mocking auth entirely
jest.mock('../middleware/auth', () => ({
  requireAuth: (req, res, next) => {
    req.user = { id: 'test_user', role: 'admin' };
    next();
  }
}));

// GOOD: use real auth middleware with a real test token
import { generateTestToken } from '../test-helpers/auth';

describe('Document API', () => {
  let adminToken: string;
  let viewerToken: string;

  beforeAll(async () => {
    adminToken = await generateTestToken({ userId: 'user_1', role: 'admin' });
    viewerToken = await generateTestToken({ userId: 'user_2', role: 'viewer' });
  });

  it('allows admin to delete documents', async () => {
    const res = await request(app)
      .delete('/api/documents/doc_1')
      .set('Authorization', `Bearer ${adminToken}`)
      .expect(200);
  });

  it('rejects viewer attempting to delete', async () => {
    const res = await request(app)
      .delete('/api/documents/doc_1')
      .set('Authorization', `Bearer ${viewerToken}`)
      .expect(403);
  });
});

Setting up a test token factory

// test-helpers/auth.ts
import jwt from 'jsonwebtoken';
import crypto from 'crypto';

// Generate a real RS256 key pair for tests
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
  modulusLength: 2048,
});

// Set the test public key in your auth config before tests run
process.env.JWT_PUBLIC_KEY = publicKey.export({ type: 'pkcs1', format: 'pem' }) as string;
process.env.JWT_ISSUER = 'https://test.bastionary.com';
process.env.JWT_AUDIENCE = 'https://test.api.example.com';

export function generateTestToken(
  claims: Record<string, any>,
  options: { expiresIn?: number; issuedAt?: number } = {}
): string {
  const now = options.issuedAt ?? Math.floor(Date.now() / 1000);
  const expiresIn = options.expiresIn ?? 900;

  return jwt.sign(
    {
      sub: claims.userId,
      iss: 'https://test.bastionary.com',
      aud: 'https://test.api.example.com',
      iat: now,
      exp: now + expiresIn,
      ...claims,
    },
    privateKey,
    { algorithm: 'RS256', keyid: 'test-key-1' }
  );
}

export function generateExpiredToken(claims: Record<string, any>): string {
  const oneHourAgo = Math.floor(Date.now() / 1000) - 3600;
  return generateTestToken(claims, {
    issuedAt: oneHourAgo - 900,
    expiresIn: 900,  // expired 1 hour ago
  });
}

JWT validation edge case tests

describe('JWT validation', () => {
  it('rejects a token with wrong issuer', async () => {
    const token = jwt.sign(
      { sub: 'user_1', iss: 'https://evil.example.com', aud: AUDIENCE, exp: future() },
      privateKey, { algorithm: 'RS256' }
    );

    const res = await request(app)
      .get('/api/user/profile')
      .set('Authorization', `Bearer ${token}`)
      .expect(401);

    expect(res.body.error).toBe('TOKEN_INVALID');
  });

  it('rejects an expired token', async () => {
    const token = generateExpiredToken({ userId: 'user_1' });
    await request(app)
      .get('/api/user/profile')
      .set('Authorization', `Bearer ${token}`)
      .expect(401);
  });

  it('rejects a token signed with a different key', async () => {
    const wrongKey = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey;
    const token = jwt.sign(
      { sub: 'user_1', iss: ISSUER, aud: AUDIENCE, exp: future() },
      wrongKey, { algorithm: 'RS256' }
    );

    await request(app)
      .get('/api/user/profile')
      .set('Authorization', `Bearer ${token}`)
      .expect(401);
  });

  it('rejects a token with wrong audience', async () => {
    const token = jwt.sign(
      { sub: 'user_1', iss: ISSUER, aud: 'https://wrong.api.example.com', exp: future() },
      privateKey, { algorithm: 'RS256' }
    );

    await request(app)
      .get('/api/user/profile')
      .set('Authorization', `Bearer ${token}`)
      .expect(401);
  });

  it('rejects the none algorithm token', async () => {
    // The "alg:none" attack: JWT with no signature
    const header = Buffer.from('{"alg":"none","typ":"JWT"}').toString('base64url');
    const payload = Buffer.from(JSON.stringify({ sub: 'user_1', iss: ISSUER, aud: AUDIENCE, exp: future() })).toString('base64url');
    const noneToken = `${header}.${payload}.`;

    await request(app)
      .get('/api/user/profile')
      .set('Authorization', `Bearer ${noneToken}`)
      .expect(401);
  });
});

Permission boundary tests

Test that users cannot access resources belonging to other users or other organizations. These are the authorization bugs that cause data breaches — not clever cryptographic attacks but simple IDOR (Insecure Direct Object Reference) bugs.

describe('Authorization boundaries', () => {
  let user1Token: string, user2Token: string;

  beforeAll(async () => {
    // Create two users in different orgs
    const org1 = await db.orgs.create({ name: 'Org One' });
    const org2 = await db.orgs.create({ name: 'Org Two' });

    const user1 = await db.users.create({ email: 'u1@example.com' });
    const user2 = await db.users.create({ email: 'u2@example.com' });

    await db.orgMembers.create({ orgId: org1.id, userId: user1.id, role: 'admin' });
    await db.orgMembers.create({ orgId: org2.id, userId: user2.id, role: 'admin' });

    user1Token = generateTestToken({ userId: user1.id, orgId: org1.id });
    user2Token = generateTestToken({ userId: user2.id, orgId: org2.id });

    // Create a document in org1
    await db.documents.create({ id: 'doc_org1', orgId: org1.id, content: 'secret' });
  });

  it('prevents user2 from reading org1 documents', async () => {
    await request(app)
      .get('/api/documents/doc_org1')
      .set('Authorization', `Bearer ${user2Token}`)
      .expect(404);  // 404, not 403 — don't reveal the resource exists
  });

  it('prevents user2 from listing org1 members', async () => {
    // Must include org scope in the request or derive from token
    const res = await request(app)
      .get('/api/orgs/org1_id/members')
      .set('Authorization', `Bearer ${user2Token}`)
      .expect(403);
  });
});
Return 404 instead of 403 when a user tries to access a resource from another tenant. Returning 403 tells the attacker the resource exists but they don't have permission — useful information for targeting. 404 reveals nothing about whether the resource ID is valid. This requires your authorization check to run before your existence check, which is worth the extra database query.

Refresh token rotation tests

describe('Refresh token rotation', () => {
  it('invalidates the old refresh token after use', async () => {
    const { refreshToken } = await login(testUser);

    // Use the token once — get a new one
    const res1 = await request(app)
      .post('/auth/token/refresh')
      .send({ refreshToken })
      .expect(200);

    const newRefreshToken = res1.body.refreshToken;
    expect(newRefreshToken).not.toBe(refreshToken);

    // Attempting to use the original token again should fail
    await request(app)
      .post('/auth/token/refresh')
      .send({ refreshToken })  // old token
      .expect(401);
  });

  it('revokes the entire family when a rotated token is replayed', async () => {
    const { refreshToken } = await login(testUser);

    // Rotate once
    const res1 = await request(app)
      .post('/auth/token/refresh')
      .send({ refreshToken })
      .expect(200);

    const token2 = res1.body.refreshToken;

    // Rotate again with the new token
    await request(app)
      .post('/auth/token/refresh')
      .send({ refreshToken: token2 })
      .expect(200);

    // Now replay the first token — should revoke the whole family
    await request(app)
      .post('/auth/token/refresh')
      .send({ refreshToken })
      .expect(401);

    // The second token (which was valid) is now also revoked
    await request(app)
      .post('/auth/token/refresh')
      .send({ refreshToken: token2 })
      .expect(401);
  });
});

The family revocation test is critical and commonly missing from auth test suites. It's the test that verifies your reuse detection actually kills all sibling tokens, not just the replayed one. Run these tests against your actual database and Redis instances, not mocks — the concurrent atomic operations that make family revocation work correctly only appear in integration tests.