The OWASP Top 10 is easy to nod along to in a slide deck and easy to violate by accident in a pull request that looked fine in review. This is what the categories that actually show up in a typical NestJS + TypeORM + Postgres API look like in real code, not abstract definitions.
Broken access control: the most common one by far
An endpoint that checks the caller is authenticated but not that they own the resource they are asking for is the single most frequent real-world finding. GET /orders/:id behind a plain AuthGuard lets any logged-in user read any other user's order by guessing or incrementing an id — the fix is comparing the resource's owner against the authenticated user, not just checking that a user exists.
// Vulnerable: any authenticated user can read any order
async getOrder(id: string) {
return this.orderRepository.findOneOrFail({ where: { id } });
}
// Fixed: scope the query to the caller
async getOrder(id: string, userId: string) {
return this.orderRepository.findOneOrFail({ where: { id, userId } });
}
Injection: TypeORM helps, but does not save you automatically
The query builder and repository methods parameterize values safely, which eliminates most SQL injection risk by default. The exception is anywhere raw SQL is built by string concatenation — a search feature, a report, an admin CSV export — because manager.query() accepts a raw string, and interpolating user input into it re-opens exactly the hole the ORM otherwise closes.
- Never interpolate a variable into
manager.query(...${input}...)— always pass it as a parameter:manager.query('SELECT * FROM x WHERE y = $1', [input]). - Cryptographic failures usually mean plaintext secrets, not a weak cipher — check that passwords are hashed with argon2/bcrypt, not encrypted, and that nothing sensitive is logged in full.
- Security misconfiguration is most often a permissive CORS policy (
origin: '*'with credentials) or a stack trace leaking to the client in production —NODE_ENV=productionshould disable both. - Server-side request forgery (SSRF) shows up in any feature that fetches a URL the user supplies — an avatar-from-URL upload, a webhook URL field — validate against a denylist of internal IP ranges before fetching.
- Insecure deserialization is rare in TypeScript directly, but
JSON.parseon unchecked input followed by trusting its shape without a validation pipe is the same risk in a milder form.
Security logging: the failure you only notice in hindsight
A breach investigation needs a trail of who did what and when — failed login attempts, permission denials, admin actions on sensitive resources. An API with no audit log cannot answer "was this account compromised" with anything better than a guess, weeks after the fact, which is exactly when the answer matters most.
@Injectable()
export class ProductAuditInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
const req = context.switchToHttp().getRequest();
return next.handle().pipe(
tap(() =>
this.auditLog.record({
actorId: req.user?.sub,
action: 'product.updated',
resourceId: req.params.id,
ipAddress: req.ip,
}),
),
);
}
}
Vulnerable and outdated components round out the list in practice: a dependency with a known CVE sitting unpatched for months is as real a risk as any code you wrote yourself.
pnpm auditin CI, run on a schedule and not just at release time, catches this before an attacker does.
Identification and authentication failures beyond the login form
This category covers more than a weak password policy: session fixation (accepting a session id supplied by the client instead of always generating a fresh one at login), predictable password-reset tokens, and an account enumeration leak where a login error message reveals whether an email exists in the system at all — "no account with that email" versus "wrong password" tells an attacker exactly which emails are worth a credential-stuffing attempt.
// Vulnerable: reveals whether the email exists
if (!user) throw new NotFoundException('No account with that email');
if (!(await verifyPassword(user.password, dto.password))) {
throw new UnauthorizedException('Wrong password');
}
// Fixed: identical response for both failure cases
if (!user || !(await verifyPassword(user.password, dto.password))) {
throw new UnauthorizedException('Invalid email or password');
}
Password-reset tokens deserve the same scrutiny as session tokens: they must be generated with a cryptographically secure random source, be single-use, expire quickly (15-30 minutes is typical), and be invalidated the moment the password is actually changed — a reset link that still works after the password was already reset is a standing vulnerability, not a convenience.
Software and data integrity failures: the supply-chain risk
This category covers trusting code or data without verifying its origin: a CI pipeline that pulls a dependency from an unpinned version range and silently gets a compromised patch release, a deploy step that installs from npm without a lockfile so the exact dependency tree is never reproducible, or an auto-update mechanism that applies a new build without verifying its signature.
# CI should install exactly what the lockfile says, not "whatever
# satisfies the version range today" — the difference matters the day
# a maintainer's account is compromised and a patch release turns malicious.
- run: pnpm install --frozen-lockfile # fails if the lockfile is out of date
# NOT: pnpm install # silently updates within range
The same principle applies to the seed script pattern used throughout this project: idempotent, find-before-create logic means a re-run cannot silently duplicate or corrupt data even if it is triggered unexpectedly — integrity is designed in at the data layer, not only trusted to whoever remembers to run it carefully.
Treating this list as a starting checklist, not a finish line
The OWASP Top 10 names the most common categories, not an exhaustive list, and a codebase that has addressed every item on it is not thereby proven secure — it has cleared the floor, not the ceiling. Business-logic vulnerabilities specific to a given product (a discount code that can be applied twice through a race condition, a workflow that lets a user skip a required approval step) fall outside any generic checklist and only surface from someone actually thinking through the specific application's rules.
A periodic, deliberate threat-modeling session for the specific product — not just running an automated scanner against the generic categories — is what catches the vulnerabilities that are unique to what this particular application actually does.
A useful habit for any team adopting this list for the first time is to map each category to the actual codebase once, concretely — which files handle authentication, which endpoints build SQL by hand, which forms accept file uploads — rather than treating the ten categories as an abstract exercise disconnected from the specific application. That one mapping exercise, revisited whenever a major feature is added, does more to catch real gaps than memorizing the category names ever will.
A related, easy-to-overlook category is insecure design — vulnerabilities baked into the architecture itself rather than a single line of buggy code, like a password reset flow that never expires old tokens, or a permission model with no way to express "read-only" access short of full admin. These are the hardest category to fix retroactively, because unlike a missing input check, they usually require a schema or API contract change, which is exactly why access-control decisions deserve real design review before the first line of implementation code is written.
Conclusion
Most of the OWASP Top 10, in a NestJS API, comes down to a small number of concrete habits: scope every query to the authenticated user's own resources, never build SQL by string concatenation, validate every input at the boundary, log security-relevant events, and keep dependencies patched. None of it is exotic — the risk is almost always a missing userId check, not a missing cryptography degree.

