Back to Blog

OWASP Top 10 for SaaS APIs — How We Audit Our Own Code

Published: 2026-06-23
OWASP Top 10 for SaaS APIs — How We Audit Our Own Code

Every client engagement at our agency starts the same way: we run a full OWASP Top 10 SaaS API security audit against the codebase before we write a single new feature. Some of these audits take two hours. Some take two days. Every single one finds at least one vulnerability that would have been a production incident within the first month. That is not hyperbole — the average data breach now costs $4.88M (IBM, 2024), and stolen credentials are the initial action in 24% of breaches (Verizon DBIR 2024). API security is not a compliance checkbox you tick once; it is the difference between a quiet Tuesday and a seven-figure incident report.

This post is the exact checklist we use. It covers the eight vulnerabilities from the OWASP API Security Top 10 that matter most for SaaS APIs — the ones that actually show up in NestJS codebases — with the specific code-level fixes we apply. The remaining two vulnerabilities on the official list (unrestricted access to sensitive flows and unsafe consumption of APIs) are important but depend heavily on your specific business logic, so we handle them on a per-project basis.

The goal is not to make you a security engineer. It is to give you a repeatable audit process that catches the mistakes that keep showing up in production NestJS applications.

OWASP Top 10 SaaS API security audit concept — a laptop displaying a security lock icon, representing application security review

Vulnerability 1: Broken Object Level Authorization (BOLA)

BOLA is the number one API vulnerability for a reason. It is trivially easy to introduce and catastrophically easy to exploit. The vulnerability is simple: an API endpoint accepts a resource identifier from the client (a user ID, document ID, order ID) and returns the resource without verifying that the authenticated user owns it.

A SaaS-specific version of this is tenant isolation bypass. A user authenticates, provides an invoice ID belonging to a different tenant, and the API returns the invoice because it never checked tenant ownership.

How to Fix BOLA in NestJS

The fix is a consistent authorization check in every route that accesses a resource by ID. Never trust the client-provided ID without verifying ownership.

TypeScript
1// ❌ Vulnerable — no ownership check
2@Get('invoices/:id')
3async getInvoice(@Param('id') id: string) {
4  return this.invoicesRepo.findOneBy({ id });
5}
6
7// ✅ Safe — ownership check against authenticated user's tenant
8@Get('invoices/:id')
9async getInvoice(@Param('id') id: string, @Request() req) {
10  const invoice = await this.invoicesRepo.findOne({
11    where: { id, tenantId: req.user.tenantId },
12  });
13  if (!invoice) throw new NotFoundException();
14  return invoice;
15}

The pattern is simple: every database query that fetches a user-scoped or tenant-scoped resource must include the authenticated user's tenant ID or user ID in the where clause. If you use TypeORM's query builder, the same principle applies — always append the tenant condition.

For a reusable approach, create a base service that injects the tenant filter automatically:

TypeScript
1export class TenantScopedService<T extends { tenantId: string }> {
2  constructor(
3    private readonly repo: Repository<T>,
4    private readonly tenantId: string,
5  ) {}
6
7  async findById(id: string): Promise<T | null> {
8    return this.repo.findOne({
9      where: { id, tenantId: this.tenantId } as any,
10    });
11  }
12}

This vulnerability is also covered in depth in our PostgreSQL row-level security guide, which provides a database-level safety net when application-level checks are missed.

Vulnerability 2: Broken Authentication

Broken authentication in SaaS APIs typically manifests as JWT validation gaps, weak token secrets, missing refresh token rotation, or session fixation in OAuth flows.

The Most Common NestJS Mistake

TypeScript
1// ❌ Vulnerable — using `none` algorithm or missing audience validation
2const payload = jwt.verify(token, secret); // no algorithm whitelist
3
4// ✅ Safe — explicit algorithm and audience validation
5const payload = jwt.verify(token, secret, {
6  algorithms: ['HS256'],
7  audience: 'https://api.yoursaas.com',
8});

Checklist for Authentication

  • JWT secret is at least 256 bits, generated with crypto.randomBytes(32).toString('hex'), and stored in environment variables — not in the codebase
  • The algorithms option is explicitly set in jwt.verify() — never omit it or include 'none'
  • Token expiration is set to 15 minutes or less for access tokens, with refresh tokens rotated on each use
  • Rate limiting is applied to login endpoints to prevent brute-force attacks
  • Session invalidation is handled server-side, not just by deleting the client-side token

For a complete implementation of secure JWT handling with refresh token rotation, see our JWT authentication with refresh tokens guide.

Broken authentication vulnerability — cybersecurity professionals analyzing data on computer screens in a security operations center

Vulnerability 3: Broken Object Property Level Authorization

This vulnerability, sometimes called "mass assignment," occurs when an API accepts more properties in a request body than the client should be allowed to set. A user updates their profile and sends { name: "New Name", role: "admin" } — and the API saves the role because it did not filter the input.

How to Fix Mass Assignment in NestJS

Use DTOs (Data Transfer Objects) with explicit property whitelisting. Never pass the full request body directly to a database update.

TypeScript
1// ❌ Vulnerable — accepts any property from the request body
2@Patch('profile')
3async updateProfile(@Body() body: any, @Request() req) {
4  return this.usersRepo.update(req.user.id, body);
5}
6
7// ✅ Safe — only allows specific properties
8export class UpdateProfileDto {
9  @IsOptional()
10  @IsString()
11  @MaxLength(100)
12  name?: string;
13
14  @IsOptional()
15  @IsString()
16  avatar?: string;
17}
18
19@Patch('profile')
20async updateProfile(@Body() dto: UpdateProfileDto, @Request() req) {
21  return this.usersRepo.update(req.user.id, dto);
22}

NestJS ValidationPipe with whitelist: true strips any properties that do not have decorators in the DTO. Enable it globally:

TypeScript
1app.useGlobalPipes(new ValidationPipe({
2  whitelist: true,        // strip unknown properties
3  forbidNonWhitelisted: true, // throw error on unknown properties
4  transform: true,
5}));

Vulnerability 4: Unrestricted Resource Consumption

APIs without rate limits, pagination limits, or request size limits are vulnerable to resource exhaustion attacks. A single client can saturate your database connections, fill your memory, or exhaust your worker threads.

How to Fix Resource Consumption in NestJS

TypeScript
1// ❌ Vulnerable — no pagination, no limit
2@Get('users')
3async getUsers() {
4  return this.usersRepo.find(); // returns every user in the database
5}
6
7// ✅ Safe — pagination with max limit
8@Get('users')
9async getUsers(
10  @Query('page') page: number = 1,
11  @Query('limit') limit: number = 20,
12) {
13  const cappedLimit = Math.min(limit, 100);
14  return this.usersRepo.find({
15    take: cappedLimit,
16    skip: (page - 1) * cappedLimit,
17  });
18}

For a complete rate limiting setup with Redis-backed sliding windows, see our API rate limiting implementation guide. The key principles are:

  • Apply rate limits per user, per API key, and per IP address independently
  • Set a maximum pagination limit (typically 100 items per page) on every list endpoint
  • Limit request body size with a global pipe or middleware (NestJS's BodySizeLimitPipe or Express's body-parser limit option)
  • Set database query timeout to prevent long-running queries from consuming connections

Vulnerability 5: Broken Function Level Authorization

This is the "admin endpoint" vulnerability. An API exposes a DELETE endpoint or an admin-only route, and a regular user discovers and calls it because the route was protected by hiding the button in the UI rather than enforcing authorization on the server.

How to Fix Function Level Authorization in NestJS

Use role-based or permission-based guards on every admin endpoint. Never rely on the frontend to hide admin actions.

TypeScript
1// ❌ Vulnerable — no guard on admin endpoint
2@Delete('users/:id')
3async deleteUser(@Param('id') id: string) {
4  return this.usersRepo.delete(id);
5}
6
7// ✅ Safe — requires admin role
8@Delete('users/:id')
9@UseGuards(JwtAuthGuard, RolesGuard)
10@Roles('admin')
11async deleteUser(@Param('id') id: string) {
12  return this.usersRepo.delete(id);
13}

Every route that performs a privileged operation must have a guard that checks the user's role or permissions. If there is no guard on a DELETE, PATCH, or admin-only POST endpoint, that is a finding in every audit.

For a complete role-based permission system implementation, see our RBAC guide for NestJS SaaS.

Vulnerability 6: Server-Side Request Forgery (SSRF)

SSRF occurs when your application makes HTTP requests to URLs provided by user input. A customer provides a webhook URL, and your server fetches it. If the URL points to http://169.254.169.254/latest/meta-data/ (the AWS metadata endpoint), your server credentials are exposed.

How to Fix SSRF in NestJS

Validate and restrict outbound HTTP requests from user-supplied URLs.

TypeScript
1// ❌ Vulnerable — accepts any URL
2async function sendWebhook(url: string, payload: object) {
3  await fetch(url, { method: 'POST', body: JSON.stringify(payload) });
4}
5
6// ✅ Safe — validates URL against allowed patterns
7async function sendWebhook(url: string, payload: object) {
8  const parsed = new URL(url);
9
10  // Block internal IP ranges
11  const blockedHosts = [
12    '169.254.169.254',  // AWS metadata
13    '127.0.0.1',        // localhost
14    '::1',              // IPv6 localhost
15    '10.',              // private network
16    '172.16.',          // private network
17    '192.168.',         // private network
18  ];
19
20  if (blockedHosts.some((prefix) => parsed.hostname.startsWith(prefix))) {
21    throw new ForbiddenException('URL not allowed');
22  }
23
24  // Only allow HTTPS URLs
25  if (parsed.protocol !== 'https:') {
26    throw new ForbiddenException('Only HTTPS URLs are allowed');
27  }
28
29  // Set a timeout
30  const controller = new AbortController();
31  const timeout = setTimeout(() => controller.abort(), 5000);
32
33  try {
34    return await fetch(url, {
35      method: 'POST',
36      body: JSON.stringify(payload),
37      signal: controller.signal,
38    });
39  } finally {
40    clearTimeout(timeout);
41  }
42}

One honest caveat: the string-prefix blocklist above is the floor, not the ceiling. It is deliberately fragile — '172.16.' only catches 172.16.x.x, while the private range actually runs 172.16.0.0 through 172.31.255.255, and a blocklist does nothing against DNS rebinding, HTTP redirects to an internal host, or an IP written in decimal. The robust fix is an allowlist of permitted hosts, plus resolving the hostname and re-checking the resolved IP against the private ranges before you make the request. The OWASP SSRF Prevention Cheat Sheet walks through the full set of bypasses the naive version misses.

For webhook systems, also verify the endpoint by sending a challenge request with a verification token before saving the URL to the database. See our outgoing webhook system guide for the full implementation.

Vulnerability 7: Security Misconfiguration

Security misconfiguration covers missing HTTP security headers, exposed debugging endpoints, verbose error messages, CORS misconfiguration, and default credentials.

How to Fix Security Misconfiguration in NestJS

TypeScript
1// main.ts — apply security headers with helmet
2import helmet from 'helmet';
3
4async function bootstrap() {
5  const app = await NestFactory.create(AppModule);
6
7  // Security headers
8  app.use(helmet());
9
10  // CORS — restrict to known origins
11  app.enableCors({
12    origin: ['https://app.yoursaas.com', 'https://admin.yoursaas.com'],
13    methods: ['GET', 'POST', 'PATCH', 'DELETE'],
14    credentials: true,
15    maxAge: 86400,
16  });
17
18  // Global prefix to avoid route conflicts
19  app.setGlobalPrefix('api');
20
21  // Hide framework fingerprinting
22  app.getHttpAdapter().getInstance().disable('x-powered-by');
23
24  await app.listen(process.env.PORT || 3000);
25}

Audit Checklist for Security Configuration

  • All HTTP security headers are set (Helmet's defaults cover 13 headers including CSP, HSTS, and X-Content-Type-Options)
  • CORS origin is restricted to specific domains, not *
  • Debug endpoints (/graphql, /swagger, /docs) are disabled or authenticated in production
  • Error responses do not include stack traces or internal details (NestJS exception filters handle this)
  • Environment variables for secrets are loaded from a secure vault, not .env files committed to the repository
  • Cookie attributes are set: httpOnly, secure, sameSite: 'lax'

The Helmet.js documentation provides a complete reference for every security header it sets and how to configure each one for your specific use case.

Vulnerability 8: Injection

Injection vulnerabilities in NestJS applications typically occur in raw database queries, dynamic query builders, and anywhere user input is concatenated into a query string without parameterization.

How to Fix Injection in NestJS

TypeScript
1// ❌ Vulnerable — raw string concatenation
2const users = await dataSource.query(
3  `SELECT * FROM users WHERE email = '${email}'`,
4);
5
6// ✅ Safe — parameterized query
7const users = await dataSource.query(
8  `SELECT * FROM users WHERE email = $1`,
9  [email],
10);
11
12// ✅ Safe — TypeORM repository (parameterized by default)
13const user = await this.usersRepo.findOneBy({ email });

TypeORM's repository methods are safe from SQL injection because they use parameterized queries internally. The risk comes from two places: raw queries in migrations or complex reporting queries, and the QueryBuilder when user input is used in where clauses without parameters.

TypeScript
1// ✅ Safe — QueryBuilder with parameters
2const users = await this.usersRepo.createQueryBuilder('user')
3  .where('user.email = :email', { email: userInput })
4  .getMany();

Never use query() or createQueryBuilder().where() with template literals or string concatenation. If you see ${variable} inside a SQL string during an audit, that is a critical finding regardless of whether the input appears to be validated elsewhere.

Audit logs of all authentication events should be stored in a separate append-only table. See our audit log implementation guide for the schema design and query patterns.

Injection vulnerability code review — software developer analyzing code on a tablet highlighting security review practices

Our Pre-Launch OWASP Top 10 SaaS API Security Audit Checklist

This is the checklist we run before every client launch. It takes roughly two hours for a typical NestJS SaaS codebase.

Authorization

  • Every route that accesses a resource by ID filters by the authenticated user's tenant or user ID
  • Admin endpoints have role or permission guards — not just frontend hiding
  • DTOs use whitelist: true to prevent mass assignment
  • No raw user ID or object ID is accepted without ownership verification

Authentication

  • JWT secrets are 256+ bits and stored in environment variables
  • Token algorithm is explicitly HS256 or RS256 — never none
  • Access tokens expire within 15 minutes
  • Refresh tokens are rotated on use (old token invalidated when new one issued)
  • Login endpoints are rate limited
  • Password fields use bcrypt with cost factor 10 or higher

Network Security

  • CORS is restricted to known frontend origins
  • Outbound HTTP requests from user-supplied URLs validate against internal IP ranges
  • Webhook URLs require HTTPS and a verification challenge
  • All external API calls have timeouts set

Configuration

  • Helmet security headers are applied globally
  • Debug endpoints (Swagger, GraphQL playground) are disabled in production
  • Error responses do not include stack traces
  • X-Powered-By header is disabled
  • Cookie attributes are set: httpOnly, secure, sameSite

Data Access

  • All list endpoints have a maximum pagination limit (100 items)
  • Database queries use parameterized inputs — no string concatenation in SQL
  • Request body size is limited
  • File upload endpoints validate file types and size

Observability

  • Authentication failures are logged with timestamp, IP, and user identifier
  • Permission denials are logged with the resource and action attempted
  • Rate limit violations are logged
  • Audit logs are append-only and stored separately from operational data

Conclusion

Security audits are not about finding every vulnerability. They are about finding the vulnerabilities that matter — the ones that show up in production and cause data breaches. The eight vulnerabilities in this checklist account for roughly 80% of the API security incidents we see in SaaS applications.

The fix for most of them follows the same pattern: validate ownership on every resource access, parameterize every database query, guard every privileged endpoint, and limit every unbounded operation. None of these fixes require a security engineer. They require discipline and a checklist.

If you run this OWASP Top 10 SaaS API security audit against your codebase today, you will likely find at least one issue. Fix it. Ship it. Run the audit again next month. That repeatable cycle is what separates applications that get hacked from applications that do not. The goal was never a perfect score — it is to never be the cautionary tale in someone else's security post.

For a deeper dive into the specific areas this checklist touches, see our guides on JWT authentication with refresh tokens, API rate limiting, and audit logging.

Frequently Asked Questions

The OWASP API Security Top 10 is a list of the ten most critical security risks for APIs, published by the Open Web Application Security Project. For SaaS applications, the most relevant vulnerabilities are Broken Object Level Authorization (BOLA), Broken Authentication, Broken Object Property Level Authorization, Unrestricted Resource Consumption, Broken Function Level Authorization, Server-Side Request Forgery (SSRF), Security Misconfiguration, and Injection.

Broken Object Level Authorization (BOLA) is the most common and most exploited API vulnerability in SaaS platforms. It occurs when an API fails to verify that the authenticated user has permission to access a specific resource object. Attackers exploit this by manipulating IDs in API requests — changing a user ID in a URL to access another tenant's data.

Fix BOLA in NestJS by implementing a consistent authorization check in every route that accesses a resource by ID. Use a reusable guard or interceptor that extracts the resource owner from the database and compares it against the authenticated user's tenant or user ID. Never trust the client-provided ID without verifying ownership against the authenticated session.

A NestJS API should set Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, and Referrer-Policy headers. The helmet middleware for Express/NestJS sets all of these with secure defaults. Additionally, disable the X-Powered-By header to avoid revealing framework information to potential attackers.

Use the repository pattern and query builder parameters instead of raw SQL string concatenation. TypeORM's repository methods (find, findOne, save) are parameterized by default and safe from injection. When you need raw queries with the query runner, always use parameterized queries with the :param syntax rather than template literals that interpolate user input.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio