Securing Your SaaS API
- security
- api-security
- nestjs
- backend
- rate-limiting
- multi-tenancy
- saas
- typescript
In Organization and Multi-Tenant Authentication we got a user logged into the right organization and made sure their session carries a tenant context that every downstream check can rely on. That solves "who is this person and which company do they belong to." It does not solve everything an attacker, a careless integration partner, or a bug in your own code can do to the API sitting behind that login. This article is about the layer that catches the rest.
This is part of the Full Stack SaaS Masterclass, a build-it-for-real series taking a multi-tenant SaaS from an empty folder to production. Module 2 closes here, on API security, before we move into the feature work that actually makes the product useful.
I want to skip the compliance-checklist version of this topic and focus on the handful of controls that actually stop the incidents I've seen happen in real SaaS codebases, why each one matters, and which ones can honestly wait.
Why authentication and authorization aren't enough on their own
JWT verification tells you a request carries a valid, unexpired token. RBAC tells you the role attached to that token is allowed to call a given endpoint. Neither check tells you whether the data being requested belongs to the tenant making the request, whether the payload is shaped the way your handler expects, or whether the same IP is hammering your login endpoint a thousand times a minute.
That's the gap between "authenticated" and "safe." A logged-in, correctly-scoped user with a legitimate JWT can still read another organization's invoice if your query doesn't filter on tenant ID. A well-behaved client library can still send an unexpected field that overwrites a column it shouldn't touch. Neither is a failure of your auth system; both are failures of everything downstream of it. Security work doesn't stop once login and RBAC ship. It moves into every controller, every DTO, and every query you write from here on.
Rate limiting and abuse prevention
Every public endpoint is a resource someone can exhaust. Login endpoints get credential-stuffed. Signup forms get scraped by bots creating throwaway accounts. Expensive report endpoints get hit in a loop by a client with a bug in its retry logic, not even malicious, just costly.
NestJS ships a @nestjs/throttler package that handles the common case well. Back it with Redis rather than in-memory storage the moment you run more than one instance of your API: in-memory counters reset per process, so a distributed attacker, or just a load balancer routing across nodes, sails right past your limit.
// app.module.ts
import { Module } from '@nestjs/common';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
import { APP_GUARD } from '@nestjs/core';
import Redis from 'ioredis';
@Module({
imports: [
ThrottlerModule.forRoot({
throttlers: [{ ttl: 60_000, limit: 100 }],
storage: new ThrottlerStorageRedisService(
new Redis(process.env.REDIS_URL as string),
),
}),
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class AppModule {}
A global limit is a safety net, not a control on its own. Tighten it per endpoint: a login route should have a much stricter limit than a general read endpoint, and it should key off something more specific than IP alone, since a shared office or VPN can put many legitimate users behind one address.
// auth.controller.ts
import { Throttle } from '@nestjs/throttler';
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@Post('login')
async login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
The tradeoff: aggressive rate limits create support tickets when a legitimate power user trips them. Log throttle hits with enough context, route, key, org if available, to tell "this is a bot" apart from "this is our biggest customer's integration doing something normal but bursty." Don't tune limits in the dark.
Input validation and mass assignment
The most common API vulnerability I see is mundane: a DTO that accepts more than the handler intended. NestJS's ValidationPipe combined with class-validator closes most of this, but only when it's configured to reject unknown fields instead of merely validating the known ones.
// main.ts
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
whitelist: true strips properties that aren't declared on the DTO. forbidNonWhitelisted: true goes further and rejects the request outright if it contains extra fields; this is the setting that actually stops mass assignment. Without it, a client sending { "email": "...", "role": "owner" } to a signup endpoint that only documents email and password can quietly get role accepted if the service layer isn't careful about which fields it reads off the DTO.
// update-user.dto.ts
import { IsEmail, IsOptional, IsString, MaxLength } from 'class-validator';
export class UpdateUserDto {
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
@MaxLength(80)
displayName?: string;
// Deliberately no `role` or `organizationId` here.
// Those are set through dedicated, permission-checked endpoints, never as
// a field a user can pass on their own profile update.
}
The pattern to watch for is a DTO that mirrors your database entity too closely. If your User entity has a role column, resist reusing a Partial<User>-shaped DTO for a general update endpoint. Define the shape of what's actually editable by the caller, and give privileged fields their own endpoints with their own authorization checks.
Enforcing tenant isolation at the API boundary
This is the control most specific to a multi-tenant SaaS, and I'd rank it the highest priority after basic auth. A tenant data leak ends customer trust immediately and is genuinely hard to explain away.
RBAC tells a request "you're allowed to call GET /invoices/:id." It doesn't tell you whether the invoice at that ID belongs to the caller's organization. That check has to happen in the query itself, every time, and it shouldn't be optional.
// invoices.service.ts
async getInvoice(invoiceId: string, organizationId: string) {
const invoice = await this.invoiceRepo.findOne({
where: { id: invoiceId, organizationId },
});
if (!invoice) {
// Same response whether the invoice doesn't exist or belongs to
// another tenant. Don't leak existence.
throw new NotFoundException('Invoice not found');
}
return invoice;
}
Two details matter here. First, organizationId comes from the authenticated request context, never from a request parameter or body, so a caller can't simply pass a different value and see another tenant's data. Second, the "not found" response is intentionally identical whether the row doesn't exist at all or belongs to a different tenant; a distinct "forbidden" response for the cross-tenant case would confirm the record exists, a small but real leak.
Repeating this filter in every query is tedious and exactly the kind of thing that gets missed under deadline pressure. A guard that injects tenant scope into the request context, paired with a repository base class or query helper that applies it automatically, cuts down the places a developer can forget the check. Once you have enough tenant-scoped tables that a missed filter becomes a real risk, Postgres row-level security is worth the setup cost as a second line of defense. It won't catch a query that filters on the wrong value. What it does catch is the missing filter: a query with no WHERE organizationId = ... at all fails closed instead of leaking rows.
Transport security, headers, and secrets hygiene
A few controls are cheap enough that there's no argument for deferring them, unlike the "add it when you need it" philosophy that applies to caching or job queues.
helmet sets a sane baseline of security headers (X-Content-Type-Options, X-Frame-Options, a reasonable Content-Security-Policy starting point) with one line:
// main.ts
import helmet from 'helmet';
app.use(helmet());
CORS deserves an explicit allowlist, not a wildcard. Access-Control-Allow-Origin: * on an API behind cookie-based sessions is a real risk; even for token-based auth, an open CORS policy makes it easy for a malicious site to script requests against your API from a victim's browser.
// main.ts
app.enableCors({
origin: [process.env.APP_URL as string, process.env.ADMIN_URL as string],
credentials: true,
});
Secrets belong in environment variables injected at deploy time, never in source, and never logged. This sounds obvious until a debug statement dumps a full request body, headers included, into production logs. Set up a logger that redacts known-sensitive keys (authorization, password, token, apiKey) at the interceptor level, so a developer adding a log line six months from now doesn't have to remember to redact it manually.
If you expose webhooks for third-party integrations, verify the signature on every incoming payload. An unsigned webhook endpoint is an unauthenticated write endpoint with extra steps.
// webhooks.controller.ts
import { createHmac, timingSafeEqual } from 'crypto';
function verifySignature(payload: string, signature: string, secret: string) {
const expected = createHmac('sha256', secret).update(payload).digest('hex');
const expectedBuf = Buffer.from(expected, 'hex');
const signatureBuf = Buffer.from(signature, 'hex');
return (
expectedBuf.length === signatureBuf.length &&
timingSafeEqual(expectedBuf, signatureBuf)
);
}
Using timingSafeEqual instead of === avoids a timing attack that could let an attacker infer the correct signature byte by byte from response latency. It's a small detail, but it's the difference between a signature check that's actually secure and one that only looks secure.
Production pitfalls worth naming directly
A few mistakes show up often enough in real codebases to call out directly. Trusting a client-supplied tenant or role field anywhere in a request body is the most common; it's the mass-assignment problem from earlier applied to authorization instead of data integrity. Every privileged field must come from the verified session, never from user input.
Verbose error messages are another. A stack trace or raw database error returned to the client reveals your ORM, table names, and sometimes query structure. Catch and map errors to generic messages at the API boundary; keep the detail in your logs, not the response.
Skipping rate limits on password reset and login "because we'll add it later" is a common shortcut, and it's one of the cheaper controls to add early, unlike, say, a full WAF setup, which genuinely can wait.
Logging full request or response bodies for debugging and forgetting to strip them before shipping is a slow-motion PII leak: it doesn't look like an incident until someone audits log retention and finds months of plaintext tokens sitting in an aggregator.
Finally, treating security as a one-time setup task rather than a standing question for every new endpoint: does this query filter on tenant ID, does this DTO whitelist its fields, does this route need its own rate limit.
Key takeaways
- Authentication and RBAC establish identity and role; neither confirms a query is scoped to the right tenant, which is a separate check you must write explicitly.
- Back rate limiting with Redis once you run more than one API instance, and set tighter limits on login and password reset than on general reads.
- Configure `ValidationPipe` with `whitelist` and `forbidNonWhitelisted` to stop mass assignment; never reuse a database-shaped DTO for user-facing updates.
- Filter every tenant-scoped query on an `organizationId` from the authenticated session, never from request input, and return identical "not found" responses for missing and cross-tenant records.
- Helmet, an explicit CORS allowlist, and HMAC-signed webhooks with `timingSafeEqual` are cheap enough to add on day one; there's no reason to defer them.
- Verbose error messages and unredacted request logging are slow leaks, not obvious incidents, which is exactly why they survive in production for so long.
Frequently asked questions
What's the difference between authentication, authorization, and API security?
Authentication verifies who's making a request. Authorization (often RBAC) verifies what their role permits. API security is the broader set of controls, rate limiting, input validation, tenant scoping, transport hardening, that protect the API even when a request is correctly authenticated and authorized but still shaped to cause harm.
Do I need a Web Application Firewall (WAF) for a SaaS API?
Not on day one. A WAF is a useful additional layer once you have real traffic and a specific threat to defend against, but it doesn't replace input validation, tenant isolation, or rate limiting at the application layer. Add it later if a concrete need shows up.
How do I prevent one tenant from seeing another tenant's data in a multi-tenant API?
Filter every tenant-scoped query on an organization ID pulled from the authenticated session, not from the request. Return a generic "not found" response whether a record doesn't exist or belongs to another tenant, and consider Postgres row-level security as a second line of defense once enough tables carry tenant-scoped data.
Should rate limiting be applied per user, per IP, or per organization?
It depends on the endpoint. Login and signup routes are usually best limited per IP, since the caller isn't authenticated yet. Authenticated endpoints are often better limited per user or per organization, since IP-based limits can unfairly throttle legitimate users behind a shared address like an office network.
What's the simplest way to stop mass assignment vulnerabilities in NestJS?
Enable `whitelist: true` and `forbidNonWhitelisted: true` on your global `ValidationPipe`, and write DTOs that describe exactly what a caller may send, rather than reusing a shape that mirrors your database entity. Privileged fields like role or tenant ID should never appear on a general-purpose update DTO.
How should I handle secrets like API keys and database passwords?
Keep them in environment variables injected at deploy time, never committed to source control. Redact known-sensitive keys at the logging layer so a future debug statement can't leak them, and rotate credentials on a schedule rather than only after a suspected compromise.
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.