IDOR Vulnerabilities in NestJS: How to Build Ownership Guards That Actually Protect Your Data
Your JWTs are working perfectly. Your database is leaking anyway — here's why authentication and authorization are not the same thing, and how to close the gap in NestJS + PostgreSQL
Senior Developer

1. The Gap Between Authentication and Authorization
Here is a NestJS controller that most developers would look at and consider secure:
@Controller('invoices')
@UseGuards(JwtAuthGuard)
export class InvoicesController {
constructor(private readonly invoicesService: InvoicesService) {}
@Get(':id')
getInvoice(@Param('id') id: string) {
return this.invoicesService.findOne(id);
}
}
async findOne(id: string): Promise<Invoice> {
const invoice = await this.repo.findOneBy({ id });
if (!invoice) throw new NotFoundException();
return invoice;
}
The JWT guard works. An anonymous attacker gets a 401. An authenticated attacker with a free-tier account gets whatever they ask for:
GET /invoices/INV-10022 → 200 OK { userId: 99, amount: 14990 }
GET /invoices/INV-10023 → 200 OK { userId: 156, amount: 3200 }
# Every invoice in the database, iterated with a for loop
The vulnerability is not the identifier. It is the missing check that the identifier belongs to the user making the request.
This is IDOR (Insecure Direct Object Reference) — also called BOLA (Broken Object Level Authorization) in API security contexts. It falls under A01:2025 in the OWASP Top 10 and API1:2023 in the OWASP API Security list. It is consistently one of the most common causes of API data breaches in SaaS applications — not because it is sophisticated, but because it is a logic bug that automated tools do not catch.
Authentication and authorization answer different questions:
flowchart LR
REQ["GET /invoices/INV-10022\nBearer eyJhbG..."] --> AUTHN
subgraph AUTHN["Authentication ✅"]
A["Is this token valid?"] --> B["Yes — user 42"]
end
subgraph AUTHZ["Authorization ❌ MISSING"]
C["Does user 42 OWN INV-10022?"] --> D["Never checked"]
end
AUTHN --> AUTHZ
AUTHZ --> DB["SELECT * FROM invoices WHERE id = $1\n← no user_id filter"]
DB --> RESP["200 OK — user 99's data"]
style AUTHZ fill:#ffe3e3,stroke:#e03131
style RESP fill:#ffe3e3,stroke:#e03131
Nearly every NestJS tutorial covers authentication in depth. Object-level authorization is almost never discussed. That is the gap.
2. Five Places IDOR Hides in NestJS
URL parameters are the obvious case. These five are the ones that slip through even after a team has been briefed.
1. Request body IDs — trusting user-supplied ownership
// VULNERABLE: userId comes from the client
@Put('profile')
@UseGuards(JwtAuthGuard)
async updateProfile(@Body() dto: UpdateProfileDto) {
return this.service.update(dto.userId, dto); // attacker sends userId: 999
}
2. Nested resource parents not validated
// VULNERABLE: checks the comment exists, not that postId belongs to the user
@Get('posts/:postId/comments/:commentId')
@UseGuards(JwtAuthGuard)
async getComment(@Param('postId') postId: string, @Param('commentId') commentId: string) {
return this.commentsService.findOne(postId, commentId);
}
3. Bulk and batch operations with mixed IDs
// VULNERABLE: processes any IDs sent, including other users' records
@Delete('invoices/bulk')
@UseGuards(JwtAuthGuard)
async bulkDelete(@Body() dto: BulkDeleteDto) {
return this.service.deleteMany(dto.ids); // no ownership filter on the array
}
4. Export and download endpoints
// VULNERABLE: export endpoints receive heavy feature testing, light security testing
@Get('reports/:reportId/export')
@UseGuards(JwtAuthGuard)
async exportReport(@Param('reportId') id: string, @Res() res: Response) {
const pdf = await this.reportsService.generatePdf(id);
res.send(pdf); // streams any report to any authenticated user
}
5. Pagination and list endpoints without user scope
// VULNERABLE: returns all records from the table, paginated
@Get('invoices')
@UseGuards(JwtAuthGuard)
async listInvoices(@Query() page: PaginationDto) {
return this.repo.findAndCount({ take: page.limit, skip: page.offset });
// ← no WHERE userId filter — attacker pages through everything
}
The list endpoint case is frequently missed because it looks like a collection route, not a resource lookup. Without a userId scope, a single authenticated user can page through the entire table.
3. Fix 1: Block Mass Assignment at the DTO Layer
TypeScript types disappear at runtime. A UpdateInvoiceDto that doesn't declare a userId field still accepts one from the client at the wire level unless you explicitly block it.
Enable whitelist validation globally:
// main.ts
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // strip fields not in the DTO
forbidNonWhitelisted: true, // reject requests that include extra fields
transform: true,
}));
Never include ownership fields in write DTOs:
// CORRECT — userId is absent. It comes from the JWT, never from the client.
export class CreateInvoiceDto {
@IsString() @IsNotEmpty() description: string;
@IsNumber() @IsPositive() amount: number;
@IsEnum(Currency) currency: Currency;
}
export class UpdateInvoiceDto extends PartialType(CreateInvoiceDto) {
// inherits the same safe fields — still no userId, orgId, or role
}
Always inject userId from the verified token:
@Post()
@UseGuards(JwtAuthGuard)
createInvoice(
@Body() dto: CreateInvoiceDto,
@CurrentUser() user: JwtPayload, // from JWT signature — not client input
) {
return this.service.create({ ...dto, userId: user.id });
}
The @CurrentUser() decorator reads from req.user, which is populated by JwtAuthGuard from the verified token — never from anything the client controls:
// decorators/current-user.decorator.ts
export const CurrentUser = createParamDecorator(
(_: unknown, ctx: ExecutionContext): JwtPayload =>
ctx.switchToHttp().getRequest().user,
);
Why
forbidNonWhitelistedoverwhitelistalone? Silent stripping makes the API behavior confusing and harder to audit. Rejecting with a 400 makes the contract explicit and logs the attempted overpost.
4. Fix 2: Scope Every PostgreSQL Query With User Context
This is the most important fix. If your repository methods are built correctly, IDOR at the query level becomes structurally impossible: a wrong userId returns nothing.
// invoices.repository.ts
@Injectable()
export class InvoicesRepository {
constructor(private readonly prisma: PrismaService) {}
findOneByOwner(id: string, userId: string) {
return this.prisma.invoice.findFirst({
where: { id, userId }, // wrong userId → null, never another user's row
});
}
findAllByOwner(userId: string, page: PaginationDto) {
return this.prisma.invoice.findMany({
where: { userId }, // scoped — never returns other users' records
take: page.limit,
skip: page.offset,
orderBy: { createdAt: 'desc' },
});
}
async updateByOwner(id: string, userId: string, data: Partial<Invoice>) {
const { count } = await this.prisma.invoice.updateMany({
where: { id, userId }, // wrong userId → count 0, no mutation
data,
});
return count > 0 ? this.findOneByOwner(id, userId) : null;
}
async deleteByOwner(id: string, userId: string): Promise<boolean> {
const { count } = await this.prisma.invoice.deleteMany({
where: { id, userId }, // wrong userId → count 0, no deletion
});
return count > 0;
}
}
// invoices.service.ts
async findOne(id: string, userId: string): Promise<Invoice> {
const invoice = await this.repo.findOneByOwner(id, userId);
if (!invoice) throw new NotFoundException('Invoice not found');
// ↑ 404 not 403 — do not reveal that the resource exists for a different user
return invoice;
}
// invoices.controller.ts
@Controller('invoices')
@UseGuards(JwtAuthGuard)
export class InvoicesController {
@Get()
list(@CurrentUser() user: JwtPayload, @Query() page: PaginationDto) {
return this.service.findAll(user.id, page);
}
@Get(':id')
get(@Param('id') id: string, @CurrentUser() user: JwtPayload) {
return this.service.findOne(id, user.id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateInvoiceDto, @CurrentUser() user: JwtPayload) {
return this.service.update(id, user.id, dto);
}
@Delete(':id') @HttpCode(204)
delete(@Param('id') id: string, @CurrentUser() user: JwtPayload) {
return this.service.delete(id, user.id);
}
}
The rule is simple: userId flows down from the JWT payload. It is never accepted from a route parameter, query string, or request body.
5. Fix 3: The Ownership Guard Pattern
Repository-scoped queries protect against IDOR at the database layer. But service methods get called from background jobs, admin scripts, and other services that bypass controllers. A guard enforces ownership at the HTTP boundary independently — catching what the service layer might miss if called from a non-HTTP context.
// guards/ownership.guard.ts
import {
CanActivate,
ExecutionContext,
Injectable,
NotFoundException,
SetMetadata,
} from '@nestjs/common';
import { ModuleRef, Reflector } from '@nestjs/core';
export interface OwnershipConfig {
service: string; // Injectable token (e.g. InvoicesService.name)
method: string; // Method that fetches the resource (unscoped)
paramName: string; // Route param key holding the resource ID
ownerField: string; // Field on the resource that holds the owner's user ID
}
export const OWNERSHIP_KEY = 'ownership';
export const CheckOwnership = (config: OwnershipConfig) =>
SetMetadata(OWNERSHIP_KEY, config);
@Injectable()
export class OwnershipGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly moduleRef: ModuleRef,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const config = this.reflector.getAllAndOverride<OwnershipConfig>(
OWNERSHIP_KEY,
[context.getHandler(), context.getClass()],
);
if (!config) return true;
const request = context.switchToHttp().getRequest();
const user = request.user as JwtPayload;
const id = request.params[config.paramName];
const service = this.moduleRef.get(config.service, { strict: false });
const resource = await service[config.method](id);
// No resource, or resource belongs to a different user → same response: 404
// Never return 403 here — it confirms the resource exists, aiding enumeration
if (!resource || String(resource[config.ownerField]) !== String(user.id)) {
throw new NotFoundException();
}
request.resource = resource; // pre-fetched — avoids a second DB call in the handler
return true;
}
}
Apply it per route:
@Controller('invoices')
@UseGuards(JwtAuthGuard)
export class InvoicesController {
@Get(':id')
@UseGuards(OwnershipGuard)
@CheckOwnership({
service: InvoicesService.name,
method: 'findOneUnsafe', // unscoped fetch — guard handles ownership
paramName: 'id',
ownerField: 'userId',
})
getInvoice(@Request() req) {
return req.resource; // already fetched and validated — no second query
}
}
The two layers do different jobs:
flowchart TD
REQ["Incoming Request"] --> JWT
JWT["JwtAuthGuard\nVerifies token"] -->|Invalid| E1["401"]
JWT -->|Valid| OWN
OWN["OwnershipGuard\nFetches resource\nChecks ownerField === user.id"]
OWN -->|"Not found or wrong owner"| E2["404"]
OWN -->|"Correct owner"| SVC
SVC["Service / Repository\nScoped query WHERE id AND userId\nBackstop if guard is bypassed"]
SVC -->|"No row returned"| E3["404"]
SVC -->|"Row returned"| OK["200 — authorized"]
style E1 fill:#ff6b6b,color:#fff
style E2 fill:#ff6b6b,color:#fff
style E3 fill:#ffa94d,color:#fff
style OK fill:#51cf66,color:#fff
The guard catches IDOR at the HTTP boundary. The scoped query is the backstop for service methods called outside the HTTP lifecycle.
Complex authorization needs? For team memberships, org hierarchies, or status-conditional rules (e.g., "only update if not finalized"), CASL integrates cleanly with NestJS and Prisma. Build the scoped query layer first — CASL adds expressiveness on top, not a replacement for it.
6. Fix 4: PostgreSQL Row-Level Security as a Backstop
All of the above is application-layer defense. RLS enforces ownership at the database itself — even a completely unguarded endpoint returns an empty result set for rows that don't belong to the session's current user.
-- Enable RLS on the table
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
-- Policy: app_user_role can only see rows where user_id matches the session variable
CREATE POLICY invoices_owner_policy ON invoices
AS PERMISSIVE FOR ALL
TO app_user_role
USING (user_id = current_setting('app.current_user_id', true)::uuid);
-- The 'true' flag means missing setting returns NULL (row hidden) rather than an error
Set the session variable per request, inside a transaction so SET LOCAL scopes correctly:
// rls.interceptor.ts — runs after JwtAuthGuard populates req.user
@Injectable()
export class RlsInterceptor implements NestInterceptor {
constructor(@InjectDataSource() private ds: DataSource) {}
async intercept(ctx: ExecutionContext, next: CallHandler) {
const user = ctx.switchToHttp().getRequest().user as JwtPayload | undefined;
if (user?.id) {
await this.ds.query('SET LOCAL app.current_user_id = $1', [user.id]);
// SET LOCAL is transaction-scoped; register this interceptor after
// your transaction middleware if you use one
}
return next.handle();
}
}
RLS is the deepest layer of defense — not a replacement for the application layer, but a guarantee that a missed guard cannot silently leak data.
7. UUIDs vs Sequential IDs
Use UUID primary keys. Sequential IDs (1, 2, 3) make enumeration trivial and expose record counts and creation velocity. UUIDs (v4) make blind guessing statistically impossible.
This is defense-in-depth, not a primary fix. An attacker who obtains a UUID through a shared link, referrer header, or log exposure can still exploit IDOR if ownership checks are absent. Obfuscating the identifier is not a substitute for verifying ownership.
// Prisma
model Invoice {
id String @id @default(uuid())
userId String
}
// TypeORM
@Entity()
export class Invoice {
@PrimaryGeneratedColumn('uuid') id: string;
@Column() userId: string;
}
Use UUIDs. Add ownership checks. Both.
8. Testing IDOR: The Two-User Pattern
Catching IDOR requires at least two authenticated sessions: one to own the resource, one to attempt unauthorized access. Most automated scanners run a single session and miss this entirely.
// test/security/invoices.idor.spec.ts
describe('IDOR — Invoices', () => {
let app: INestApplication;
let aliceToken: string;
let bobToken: string;
let aliceInvoiceId: string;
beforeAll(async () => {
const module = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = module.createNestApplication();
app.useGlobalPipes(new ValidationPipe({
whitelist: true, forbidNonWhitelisted: true, transform: true,
}));
await app.init();
// Create two independent users
await request(app.getHttpServer()).post('/auth/register')
.send({ email: 'alice@test.com', password: 'AlicePass1!' });
await request(app.getHttpServer()).post('/auth/register')
.send({ email: 'bob@test.com', password: 'BobPass1!' });
aliceToken = (await request(app.getHttpServer()).post('/auth/login')
.send({ email: 'alice@test.com', password: 'AlicePass1!' })).body.access_token;
bobToken = (await request(app.getHttpServer()).post('/auth/login')
.send({ email: 'bob@test.com', password: 'BobPass1!' })).body.access_token;
// Alice creates a resource
aliceInvoiceId = (await request(app.getHttpServer())
.post('/invoices')
.set('Authorization', `Bearer ${aliceToken}`)
.send({ description: 'Alice Invoice', amount: 499, currency: 'USD' })).body.id;
});
afterAll(() => app.close());
it('Alice can read her own invoice', () =>
request(app.getHttpServer())
.get(`/invoices/${aliceInvoiceId}`)
.set('Authorization', `Bearer ${aliceToken}`)
.expect(200));
it('Bob CANNOT read Alice\'s invoice', () =>
request(app.getHttpServer())
.get(`/invoices/${aliceInvoiceId}`)
.set('Authorization', `Bearer ${bobToken}`)
.expect(404)); // Must be 404 — never 200 or 403
it('Bob CANNOT update Alice\'s invoice', async () => {
await request(app.getHttpServer())
.patch(`/invoices/${aliceInvoiceId}`)
.set('Authorization', `Bearer ${bobToken}`)
.send({ description: 'Tampered' })
.expect(404);
// Verify the invoice was not modified
const res = await request(app.getHttpServer())
.get(`/invoices/${aliceInvoiceId}`)
.set('Authorization', `Bearer ${aliceToken}`)
.expect(200);
expect(res.body.description).toBe('Alice Invoice');
});
it('Bob CANNOT delete Alice\'s invoice', async () => {
await request(app.getHttpServer())
.delete(`/invoices/${aliceInvoiceId}`)
.set('Authorization', `Bearer ${bobToken}`)
.expect(404);
// Alice's invoice must still exist
await request(app.getHttpServer())
.get(`/invoices/${aliceInvoiceId}`)
.set('Authorization', `Bearer ${aliceToken}`)
.expect(200);
});
it('Bulk delete cannot include IDs from other users', async () => {
await request(app.getHttpServer())
.delete('/invoices/bulk')
.set('Authorization', `Bearer ${bobToken}`)
.send({ ids: [aliceInvoiceId] });
// Alice's invoice must be untouched regardless of status code
await request(app.getHttpServer())
.get(`/invoices/${aliceInvoiceId}`)
.set('Authorization', `Bearer ${aliceToken}`)
.expect(200);
});
it('Bob\'s invoice list does not contain Alice\'s invoices', async () => {
const res = await request(app.getHttpServer())
.get('/invoices')
.set('Authorization', `Bearer ${bobToken}`)
.expect(200);
const ids = res.body.data?.map((i: { id: string }) => i.id) ?? [];
expect(ids).not.toContain(aliceInvoiceId);
});
it('Mass assignment attempt is rejected', () =>
request(app.getHttpServer())
.patch(`/invoices/${aliceInvoiceId}`)
.set('Authorization', `Bearer ${bobToken}`)
.send({ userId: 'new-owner-id' })
.expect(400)); // forbidNonWhitelisted rejects unrecognised fields
it('Unauthenticated request is rejected', () =>
request(app.getHttpServer())
.get(`/invoices/${aliceInvoiceId}`)
.expect(401));
});
Add this test file to every resource module. The pattern is always: User A creates → User B attempts every verb → assert 404 → verify resource is unchanged.
9. Detecting Active Enumeration
Even with all fixes in place, monitor for active probing. The signature is distinctive: a single authenticated user generating a high 404 rate on resource endpoints.
// middleware/idor-detection.middleware.ts
@Injectable()
export class IdorDetectionMiddleware implements NestMiddleware {
private readonly logger = new Logger('ThreatDetection');
private readonly RESOURCE_PATH = /^\/(invoices|orders|reports|files)\//;
constructor(@InjectRedis() private readonly redis: Redis) {}
use(req: any, res: any, next: Function): void {
next();
res.on('finish', async () => {
try {
if (res.statusCode !== 404 || !req.user?.id || !this.RESOURCE_PATH.test(req.path)) return;
const key = `idor:404:${req.user.id}`;
// Pipeline makes incr + expire atomic — avoids the race condition
// of a separate incr() call followed by a separate expire() call
const [[, count]] = await this.redis.pipeline()
.incr(key)
.expire(key, 60)
.exec() as [[null, number]];
if (count >= 10) {
this.logger.warn({
event: 'POTENTIAL_IDOR_ENUMERATION',
userId: req.user.id,
ip: req.ip,
path: req.path,
count404: count,
window: '60s',
});
}
} catch { /* never let monitoring break request handling */ }
});
}
}
Wire this up globally and route the structured log to your observability platform. A single user generating 10+ 404s per minute on resource endpoints warrants investigation.
10. Checklist
Before shipping any endpoint that touches user-owned data:
DTO Layer
☐ ValidationPipe: whitelist: true, forbidNonWhitelisted: true, globally
☐ No write DTO (Create/Update) contains userId, orgId, or role fields
☐ Response DTOs use @Exclude() on internal fields (userId, deletedAt)
Controller Layer
☐ Every resource endpoint has @UseGuards(JwtAuthGuard)
☐ userId always comes from @CurrentUser() — never from @Body() or @Param()
☐ Resource-specific routes (GET/PATCH/DELETE /:id) have @UseGuards(OwnershipGuard)
Repository / Service Layer
☐ Every findOne / update / delete takes userId as a required parameter
☐ userId is in every WHERE clause — never optional
☐ List/pagination endpoints scoped by userId — never unfiltered
☐ Bulk operations filter input IDs against the requesting user's owned IDs
☐ Soft-delete queries include userId even when withDeleted: true
Testing
☐ Every resource module has an IDOR test: User A creates → User B attempts all verbs
☐ Bulk endpoint test: User B's request cannot affect User A's records
☐ List endpoint test: User B's response never contains User A's resource IDs
☐ IDOR tests run in CI on every PR
11. FAQ
Q: Should I return 403 or 404 when ownership fails? Return 404. A 403 tells the attacker the resource exists, confirming the ID is valid and encouraging further enumeration. A 404 reveals nothing — the resource either doesn't exist or doesn't belong to the requesting user. Apply this consistently: an inconsistent response pattern across endpoints becomes an oracle.
Q: Is IDOR only a reading problem? No — writes are often more damaging and harder to detect. Blind IDOR writes don't return the victim's data, so they look less obviously wrong, but they can silently overwrite records, cancel orders, or delete content. Test every HTTP verb (GET, PATCH, PUT, DELETE) in your two-user suite, not just GET.
Q: Does @Roles() or any role guard solve IDOR? No. Role guards answer "does this user have the admin role?" They don't answer "does this user own this specific row?" IDOR is a horizontal authorization problem — two users with the same role accessing each other's data. Role guards are vertical access control. You need both.
Q: How do I handle resources that belong to a team, not a single user? Extend both layers. At the repository layer: WHERE user_id = $1 OR team_id = ANY($2). At the guard layer: check user.teamIds.includes(resource.teamId) alongside the userId check. CASL with @casl/prisma can generate these compound WHERE clauses automatically from your defined ability rules.
12. Further Reading
OWASP
Deep Dives
📰 IDOR Vulnerabilities: The Complete Technical Guide (2026) — CodeAnt
📰 DTOs That Don't Spill: Secure NestJS Validation Patterns — Modexa
Reference
Run the two-user test against every existing endpoint before the next release — not just new ones. IDOR is most commonly found in code that was already live.
Comments (0)
Login to post a comment.