SaaS API Testing Strategies: Rate Limits, Versioning, and Multi-Tenant Auth

SaaS API Testing Strategies: Rate Limits, Versioning, and Multi-Tenant Auth

A well-tested SaaS API is one of the strongest competitive advantages a product can have. Developers who integrate your API need to trust that authentication works predictably, rate limits are enforced fairly, and older API versions continue to behave as documented. When any of these break, the cost is not just one unhappy user — it is every developer who built an integration, and every end user who depends on that integration.

This guide covers the full spectrum of SaaS API testing: multi-tenant authentication, OAuth scope enforcement, rate limit behavior, API versioning compatibility, pagination correctness, and webhook reliability. Every section includes test code you can adapt to your stack.

Testing API Key Authentication

API keys are the most common authentication mechanism for SaaS APIs. They look simple to test but have numerous edge cases: key scoping, key rotation, key revocation, and tenant isolation all need explicit test coverage.

Basic API Key Validation

// api-auth.test.js
describe('API Key Authentication', () => {
  let validApiKey;
  let tenantId;

  beforeAll(async () => {
    const tenant = await createTestTenant();
    tenantId = tenant.id;
    validApiKey = await createApiKey(tenantId, { scopes: ['read', 'write'] });
  });

  it('should accept valid API key in Authorization header', async () => {
    const res = await request(app)
      .get('/api/v1/projects')
      .set('Authorization', `Bearer ${validApiKey}`);

    expect(res.status).toBe(200);
  });

  it('should accept valid API key in X-API-Key header', async () => {
    const res = await request(app)
      .get('/api/v1/projects')
      .set('X-API-Key', validApiKey);

    expect(res.status).toBe(200);
  });

  it('should reject missing API key', async () => {
    const res = await request(app).get('/api/v1/projects');

    expect(res.status).toBe(401);
    expect(res.body).toMatchObject({
      error: { code: 'MISSING_AUTH', message: expect.any(String) },
    });
  });

  it('should reject revoked API key', async () => {
    const revokedKey = await createApiKey(tenantId);
    await revokeApiKey(revokedKey);

    const res = await request(app)
      .get('/api/v1/projects')
      .set('Authorization', `Bearer ${revokedKey}`);

    expect(res.status).toBe(401);
    expect(res.body.error.code).toBe('REVOKED_KEY');
  });

  it('should reject API key from different tenant when accessing tenant resources', async () => {
    const otherTenant = await createTestTenant();
    const otherKey = await createApiKey(otherTenant.id);

    const projectId = await createProject(tenantId, 'Tenant A Project');

    const res = await request(app)
      .get(`/api/v1/projects/${projectId}`)
      .set('Authorization', `Bearer ${otherKey}`);

    expect([403, 404]).toContain(res.status);
  });
});

API Key Scope Enforcement

describe('API Key Scope Enforcement', () => {
  it('should allow read-only key to GET resources', async () => {
    const readOnlyKey = await createApiKey(tenantId, { scopes: ['read'] });

    const res = await request(app)
      .get('/api/v1/projects')
      .set('Authorization', `Bearer ${readOnlyKey}`);

    expect(res.status).toBe(200);
  });

  it('should block read-only key from writing', async () => {
    const readOnlyKey = await createApiKey(tenantId, { scopes: ['read'] });

    const res = await request(app)
      .post('/api/v1/projects')
      .set('Authorization', `Bearer ${readOnlyKey}`)
      .send({ name: 'New Project' });

    expect(res.status).toBe(403);
    expect(res.body.error.code).toBe('INSUFFICIENT_SCOPE');
  });

  it('should enforce fine-grained resource scopes', async () => {
    const billingKey = await createApiKey(tenantId, { scopes: ['billing:read'] });

    const billingRes = await request(app)
      .get('/api/v1/invoices')
      .set('Authorization', `Bearer ${billingKey}`);
    expect(billingRes.status).toBe(200);

    const projectsRes = await request(app)
      .get('/api/v1/projects')
      .set('Authorization', `Bearer ${billingKey}`);
    expect(projectsRes.status).toBe(403);
  });
});

Testing OAuth 2.0 Scopes Per Tenant

SaaS products with third-party OAuth integrations must ensure scope grants are tenant-scoped and cannot be escalated.

describe('OAuth Scope Isolation', () => {
  it('should not allow one tenant OAuth token to access another tenant data', async () => {
    const tenantA = await createTestTenant();
    const tenantB = await createTestTenant();

    const tokenA = await exchangeOAuthCode(tenantA.id, ['projects:read']);
    const projectB = await createProject(tenantB.id, 'B Secret');

    const res = await request(app)
      .get(`/api/v1/projects/${projectB}`)
      .set('Authorization', `Bearer ${tokenA}`);

    expect([403, 404]).toContain(res.status);
  });

  it('should reject access_token with expired scope grant', async () => {
    const token = await exchangeOAuthCode(tenantId, ['projects:write']);
    await revokeOAuthGrant(tenantId, 'projects:write');

    const res = await request(app)
      .post('/api/v1/projects')
      .set('Authorization', `Bearer ${token}`)
      .send({ name: 'Should Fail' });

    expect(res.status).toBe(401);
    expect(res.body.error.code).toMatch(/revoked|invalid/i);
  });
});

Testing Rate Limit Behavior

Rate limits protect your API from abuse and ensure fair access. Testing them requires verifying both the enforcement (requests get blocked when limits are exceeded) and the headers (clients can implement back-off correctly).

describe('Rate Limiting', () => {
  it('should include rate limit headers on every response', async () => {
    const res = await request(app)
      .get('/api/v1/projects')
      .set('Authorization', `Bearer ${validApiKey}`);

    expect(res.headers['x-ratelimit-limit']).toBeDefined();
    expect(res.headers['x-ratelimit-remaining']).toBeDefined();
    expect(res.headers['x-ratelimit-reset']).toBeDefined();
  });

  it('should return 429 when rate limit is exceeded', async () => {
    const limitedKey = await createApiKey(tenantId, { rateLimit: 5 });

    const requests = Array.from({ length: 6 }, () =>
      request(app)
        .get('/api/v1/projects')
        .set('Authorization', `Bearer ${limitedKey}`)
    );

    const responses = await Promise.all(requests);
    const statusCodes = responses.map(r => r.status);

    expect(statusCodes.filter(s => s === 200).length).toBe(5);
    expect(statusCodes.filter(s => s === 429).length).toBe(1);
  });

  it('should include Retry-After header on 429 response', async () => {
    const limitedKey = await createApiKey(tenantId, { rateLimit: 1 });

    await request(app).get('/api/v1/projects').set('Authorization', `Bearer ${limitedKey}`);

    const res = await request(app)
      .get('/api/v1/projects')
      .set('Authorization', `Bearer ${limitedKey}`);

    expect(res.status).toBe(429);
    expect(res.headers['retry-after']).toBeDefined();
    expect(parseInt(res.headers['retry-after'])).toBeGreaterThan(0);
  });

  it('should rate limit per tenant, not globally', async () => {
    const tenantA = await createTestTenant();
    const tenantB = await createTestTenant();
    const keyA = await createApiKey(tenantA.id, { rateLimit: 2 });
    const keyB = await createApiKey(tenantB.id, { rateLimit: 2 });

    // Exhaust Tenant A's limit
    await request(app).get('/api/v1/projects').set('Authorization', `Bearer ${keyA}`);
    await request(app).get('/api/v1/projects').set('Authorization', `Bearer ${keyA}`);
    const resA = await request(app).get('/api/v1/projects').set('Authorization', `Bearer ${keyA}`);
    expect(resA.status).toBe(429);

    // Tenant B should not be affected
    const resB = await request(app).get('/api/v1/projects').set('Authorization', `Bearer ${keyB}`);
    expect(resB.status).toBe(200);
  });
});

Testing API Versioning Compatibility

API versioning is a promise to your integrators that existing behavior will not break. Every version must be independently testable, and breaking changes must be caught before they ship.

describe('API Versioning', () => {
  describe('v1 backward compatibility', () => {
    it('should maintain v1 response shape when v2 changes field names', async () => {
      const v1Res = await request(app)
        .get('/api/v1/projects')
        .set('Authorization', `Bearer ${validApiKey}`);

      const v2Res = await request(app)
        .get('/api/v2/projects')
        .set('Authorization', `Bearer ${validApiKey}`);

      // v1 uses 'created_at', v2 uses 'createdAt (camelCase)
      expect(v1Res.body.data[0]).toHaveProperty('created_at');
      expect(v2Res.body.data[0]).toHaveProperty('createdAt');

      // Both should have the same project count
      expect(v1Res.body.data.length).toBe(v2Res.body.data.length);
    });

    it('should route to correct handler based on Accept header versioning', async () => {
      const v1Res = await request(app)
        .get('/api/projects')
        .set('Authorization', `Bearer ${validApiKey}`)
        .set('Accept', 'application/vnd.helpmetest.v1+json');

      const v2Res = await request(app)
        .get('/api/projects')
        .set('Authorization', `Bearer ${validApiKey}`)
        .set('Accept', 'application/vnd.helpmetest.v2+json');

      expect(v1Res.body.api_version).toBe('v1');
      expect(v2Res.body.api_version).toBe('v2');
    });

    it('should return deprecation warning header for sunset versions', async () => {
      const res = await request(app)
        .get('/api/v1/projects')
        .set('Authorization', `Bearer ${validApiKey}`);

      // If v1 is deprecated but not sunset yet
      if (process.env.V1_DEPRECATED === 'true') {
        expect(res.headers['deprecation']).toBeDefined();
        expect(res.headers['sunset']).toBeDefined();
      }
    });
  });
});

Testing Pagination

Incorrect pagination is a data integrity bug disguised as a performance feature. If pages overlap or skip records during concurrent writes, your API clients get inconsistent results.

describe('Pagination', () => {
  beforeAll(async () => {
    // Create 25 test projects
    for (let i = 0; i < 25; i++) {
      await createProject(tenantId, `Project ${i}`);
    }
  });

  it('should paginate results with cursor-based pagination', async () => {
    const page1 = await request(app)
      .get('/api/v1/projects?limit=10')
      .set('Authorization', `Bearer ${validApiKey}`);

    expect(page1.body.data.length).toBe(10);
    expect(page1.body.meta.next_cursor).toBeDefined();

    const page2 = await request(app)
      .get(`/api/v1/projects?limit=10&cursor=${page1.body.meta.next_cursor}`)
      .set('Authorization', `Bearer ${validApiKey}`);

    expect(page2.body.data.length).toBe(10);

    const ids1 = page1.body.data.map(p => p.id);
    const ids2 = page2.body.data.map(p => p.id);
    const overlap = ids1.filter(id => ids2.includes(id));
    expect(overlap.length).toBe(0);
  });

  it('should return consistent total count across pages', async () => {
    let allIds = [];
    let cursor = null;

    do {
      const url = cursor
        ? `/api/v1/projects?limit=10&cursor=${cursor}`
        : '/api/v1/projects?limit=10';

      const res = await request(app)
        .get(url)
        .set('Authorization', `Bearer ${validApiKey}`);

      allIds = allIds.concat(res.body.data.map(p => p.id));
      cursor = res.body.meta.next_cursor;
    } while (cursor);

    expect(allIds.length).toBe(25);
    expect(new Set(allIds).size).toBe(25); // No duplicates
  });

  it('should reject invalid limit values', async () => {
    const res = await request(app)
      .get('/api/v1/projects?limit=99999')
      .set('Authorization', `Bearer ${validApiKey}`);

    expect([400, 422]).toContain(res.status);
    expect(res.body.error).toBeDefined();
  });
});

Testing Webhook Reliability

Outbound webhooks from your SaaS to customers are as important as inbound webhooks from payment processors. Test that your delivery system handles retries, signing, and failure correctly.

describe('Outbound Webhook Delivery', () => {
  let webhookReceiver;
  let receivedPayloads = [];

  beforeAll(async () => {
    // Start a local webhook receiver
    webhookReceiver = express();
    webhookReceiver.post('/hook', (req, res) => {
      receivedPayloads.push(req.body);
      res.sendStatus(200);
    });
    await new Promise(resolve => webhookReceiver.listen(9999, resolve));
  });

  it('should sign webhook payloads with HMAC', async () => {
    let capturedRequest;

    webhookReceiver.post('/hook-signed', (req, res) => {
      capturedRequest = req;
      res.sendStatus(200);
    });

    await triggerProjectCreatedEvent(tenantId);

    expect(capturedRequest.headers['x-signature']).toBeDefined();

    const expectedSig = crypto
      .createHmac('sha256', tenantWebhookSecret)
      .update(JSON.stringify(capturedRequest.body))
      .digest('hex');

    expect(capturedRequest.headers['x-signature']).toBe(`sha256=${expectedSig}`);
  });

  it('should retry webhook delivery on 5xx response', async () => {
    let callCount = 0;

    webhookReceiver.post('/hook-retry', (req, res) => {
      callCount++;
      if (callCount < 3) {
        res.sendStatus(503);
      } else {
        res.sendStatus(200);
      }
    });

    await triggerProjectCreatedEvent(tenantId, 'http://localhost:9999/hook-retry');

    // Wait for retries (with exponential back-off)
    await new Promise(resolve => setTimeout(resolve, 5000));

    expect(callCount).toBe(3);

    const delivery = await db('webhook_deliveries')
      .where({ tenant_id: tenantId })
      .orderBy('created_at', 'desc')
      .first();

    expect(delivery.status).toBe('delivered');
    expect(delivery.attempt_count).toBe(3);
  });
});

Error Response Consistency

One of the most frustrating experiences for API consumers is inconsistent error formats. Every error from your API should follow the same schema.

describe('Error Response Consistency', () => {
  const errorEndpoints = [
    { method: 'get', path: '/api/v1/projects/nonexistent', expectedStatus: 404 },
    { method: 'post', path: '/api/v1/projects', body: {}, expectedStatus: 422 },
    { method: 'get', path: '/api/v1/admin/users', expectedStatus: 403 },
  ];

  const errorSchema = {
    type: 'object',
    required: ['error'],
    properties: {
      error: {
        type: 'object',
        required: ['code', 'message'],
        properties: {
          code: { type: 'string' },
          message: { type: 'string' },
        },
      },
    },
  };

  errorEndpoints.forEach(({ method, path, body, expectedStatus }) => {
    it(`${method.toUpperCase()} ${path} should return consistent error format`, async () => {
      const req = request(app)[method](path).set('Authorization', `Bearer ${validApiKey}`);
      if (body) req.send(body);

      const res = await req;

      expect(res.status).toBe(expectedStatus);
      expect(res.body).toMatchObject({ error: { code: expect.any(String), message: expect.any(String) } });
      expect(res.headers['content-type']).toMatch(/application\/json/);
    });
  });
});

A Testing Pyramid for SaaS APIs

Effective SaaS API testing follows a pyramid: many unit tests for business logic, a meaningful number of integration tests for API contracts, and a smaller set of end-to-end tests for critical flows.

For multi-tenant SaaS specifically, keep cross-tenant isolation tests in a dedicated suite that runs on every deploy — not just in CI. Tenant isolation bugs are the kind that slip through on Tuesday and surface in a security incident on Friday. Rate limit tests should run against a staging environment with real Redis or your rate-limiting service, not mocked in-memory counters. And versioning tests should live in a contract test suite that verifies both sides of the API boundary automatically.

The investment in comprehensive API test coverage pays back every time you ship a refactor with confidence rather than anxiety.

Read more