Skip to content
ExponaBLOG
NESTJS · DAY 077 / 100

Mass assignment in NestJS: practical tips and trade-offs

The bug where users promote themselves to admin.

Expona Admin10 min read

Introduction

We've all seen it happen on a staging server: a junior engineer leaves a user endpoint open, and suddenly accounts have admin permissions they shouldn't have. Mass assignment in NestJS: practical tips and trade-offs is about preventing exactly this class of vulnerability. When you pass a request body directly into your database layer without filtering, attackers—or careless users—can inject fields that were never meant to be user-modifiable. This article walks through real patterns we've used in production to lock this down.

Mass assignment vulnerabilities are surprisingly common because they hide in plain sight. A developer writes what feels like straightforward code, and it works perfectly during testing. But the moment that endpoint touches production, someone—a user with ambitions, a competitor testing your API, or an internal team member—discovers they can modify fields they shouldn't. In one real scenario we dealt with, a user was able to set their own isVerified flag to true, bypassing email confirmation. In another, a ride-hailing platform's riders set their own completion ratings. The damage compounds quickly.

Never pass request bodies straight into Prisma

The most dangerous habit is treating the HTTP request body as gospel. Consider this common pattern:

typescript
@Post('users')
async createUser(@Body() body: any) {
  return this.prisma.user.create({
    data: body,
  });
}

If your client sends:

typescript
{
  "email": "attacker@example.com",
  "name": "Test User",
  "role": "admin",
  "stripeCustomerId": "cus_xxx"
}

All four fields land in the database. The attacker bypassed your entire permission model in one request. We've caught this in code review countless times—it's not malice, just convenience that breaks the application.

The problem compounds with Prisma's flexibility. Prisma doesn't validate the shape of your data object at runtime; it trusts that you've filtered it. If your schema allows a field, Prisma writes it. Your database becomes the weakest link in the chain.

Real-world example: an e-commerce platform accepted a product update endpoint that allowed sellers to modify their own product listings. The developer wrote:

typescript
@Patch('products/:id')
async updateProduct(@Param('id') id: string, @Body() body: any) {
  return this.prisma.product.update({
    where: { id },
    data: body,
  });
}

A seller discovered they could also modify the vendorCommissionRate, effectively lowering their fees by setting it to 0. The hole existed for three weeks before detection.

The fix is simple in principle: always validate and shape the input before it touches the database. In NestJS, this means using Data Transfer Objects (DTOs) and validation decorators:

typescript
import { IsEmail, IsString, MaxLength } from 'class-validator';

export class CreateUserDto {
  @IsEmail()
  email: string;

  @IsString()
  @MaxLength(100)
  name: string;
}

@Post('users')
async createUser(@Body() body: CreateUserDto) {
  return this.prisma.user.create({
    data: body,
  });
}

Now the request class-validator pipeline rejects any fields not explicitly declared. Attempts to include role or stripeCustomerId fail validation before reaching your service layer. The request is rejected with a 400 Bad Request before your business logic even runs.

This approach gives you multiple benefits: documentation (the DTO shows exactly what fields are accepted), type safety (TypeScript catches mapping errors), and security (unknown fields are rejected by default).

Whitelist removes unexpected fields

NestJS class-validator also supports a whitelist mode that silently strips unexpected properties instead of throwing errors. This is often safer in production because you control the behavior:

typescript
import { ValidationPipe } from '@nestjs/common';

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: false,
    transform: true,
  }),
);

With whitelist: true, any field on the incoming request that isn't defined in your DTO simply vanishes. The attacker's role: "admin" is silently dropped. Set forbidNonWhitelisted: true if you want to throw an error instead, which is stricter but better for catching client bugs during development.

The trade-off matters. In production, we typically use forbidNonWhitelisted: false because you want the request to succeed even if a mobile app sends extra fields your backend hasn't seen yet. You don't want to break client calls. But in development and staging, forbidNonWhitelisted: true catches bugs early.

Consider a scenario: your frontend starts sending a marketingConsent field for A/B testing, but your backend doesn't define it in the DTO yet. With strict mode, legitimate frontend clients break. With whitelist mode, the field is safely ignored until your backend is ready to handle it. This is why we recommend:

typescript
const isProduction = process.env.NODE_ENV === 'production';

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: !isProduction,
    transform: true,
  }),
);

In our experience, whitelisting is the default you should use everywhere. It's defensive without being loud, and it pairs well with audit logging—log every request that tries to set forbidden fields so your security team can investigate patterns. You might structure it like:

typescript
@Post('users')
async createUser(
  @Body() body: CreateUserDto,
  @Req() req: Request,
) {
  const allKeys = Object.keys(req.body || {});
  const allowedKeys = Object.keys(new CreateUserDto());
  const extraKeys = allKeys.filter(k => !allowedKeys.includes(k));
  
  if (extraKeys.length > 0) {
    this.logger.warn('Extra fields attempted in createUser', {
      userId: req.user?.id,
      ip: req.ip,
      extraKeys,
    });
  }

  return this.prisma.user.create({
    data: body,
  });
}

This logs every attempt to inject unexpected fields, giving your security and ops teams visibility into probing attacks.

Separate DTOs for create, update and admin

Different endpoints need different rule sets. A user updating their own profile should never touch the stripeCustomerId or role fields. An admin endpoint might allow both. DTOs let you express these boundaries clearly:

typescript
// User can update their own profile
export class UpdateProfileDto {
  @IsString()
  @IsOptional()
  @MaxLength(100)
  name?: string;

  @IsString()
  @IsOptional()
  @MaxLength(500)
  bio?: string;
}

// Only admins can modify billing and permissions
export class UpdateUserAsAdminDto {
  @IsEmail()
  @IsOptional()
  email?: string;

  @IsString()
  @IsOptional()
  role?: 'user' | 'moderator' | 'admin';

  @IsString()
  @IsOptional()
  stripeCustomerId?: string;

  @IsBoolean()
  @IsOptional()
  isActive?: boolean;

  @IsString()
  @IsOptional()
  @MaxLength(1000)
  adminNotes?: string;
}

Then wire them to separate endpoints:

typescript
@Patch('profile')
@UseGuards(AuthGuard)
async updateOwnProfile(
  @Req() req: Request,
  @Body() body: UpdateProfileDto,
) {
  return this.usersService.update(req.user.id, body);
}

@Patch('users/:id')
@UseGuards(AuthGuard, AdminGuard)
async updateUserAsAdmin(
  @Param('id') userId: string,
  @Body() body: UpdateUserAsAdminDto,
) {
  return this.usersService.updateAsAdmin(userId, body);
}

This pattern makes the allowed fields explicit at the route level. A new team member reading the code immediately understands what a user can and cannot change. In a real codebase, you'd add integration tests that verify a non-admin request to the admin endpoint fails. For example:

typescript
describe('Users - Admin Update', () => {
  it('should reject non-admin updating role field', async () => {
    const response = await request(app.getHttpServer())
      .patch(`/users/${userId}`)
      .set('Authorization', `Bearer ${userToken}`)
      .send({ role: 'admin' });

    expect(response.status).toBe(403);
  });

  it('should allow admin updating role field', async () => {
    const response = await request(app.getHttpServer())
      .patch(`/users/${userId}`)
      .set('Authorization', `Bearer ${adminToken}`)
      .send({ role: 'moderator' });

    expect(response.status).toBe(200);
    expect(response.body.role).toBe('moderator');
  });
});

These tests ensure the boundary between regular and admin endpoints stays enforced, even as the codebase evolves.

Map DTOs to entities explicitly

The final guard is explicit mapping. Don't assume the DTO structure matches your Prisma schema. Build a layer that decides which DTO fields map to which database fields:

typescript
export class UsersService {
  async update(userId: string, dto: UpdateProfileDto) {
    // Only map fields we explicitly allow
    const data = {
      name: dto.name,
      bio: dto.bio,
      updatedAt: new Date(),
    };

    return this.prisma.user.update({
      where: { id: userId },
      data,
    });
  }

  async updateAsAdmin(userId: string, dto: UpdateUserAsAdminDto) {
    // Different mapping for admin endpoint
    const data: Prisma.UserUpdateInput = {};

    if (dto.email !== undefined) data.email = dto.email;
    if (dto.role !== undefined) data.role = dto.role;
    if (dto.stripeCustomerId !== undefined) {
      data.stripeCustomerId = dto.stripeCustomerId;
    }
    if (dto.isActive !== undefined) data.isActive = dto.isActive;
    if (dto.adminNotes !== undefined) data.adminNotes = dto.adminNotes;

    data.updatedAt = new Date();

    return this.prisma.user.update({
      where: { id: userId },
      data,
    });
  }
}

This approach has saved us more than once. Even if someone modifies the DTO class and accidentally adds a new field, the service layer mapping only touches the fields you intended. It's explicit, auditable, and defensive. The reason we check !== undefined instead of just assigning is to avoid accidentally nullifying fields when the client doesn't send them.

In larger teams, we often use a mapper class or library to keep this DRY and reduce boilerplate:

typescript
export class UserMapper {
  static toDatabaseUpdate(dto: UpdateProfileDto): Prisma.UserUpdateInput {
    return {
      name: dto.name,
      bio: dto.bio,
      updatedAt: new Date(),
    };
  }

  static toDatabaseAdminUpdate(
    dto: UpdateUserAsAdminDto,
  ): Prisma.UserUpdateInput {
    const data: Prisma.UserUpdateInput = {};

    if (dto.email !== undefined) data.email = dto.email;
    if (dto.role !== undefined) data.role = dto.role;
    if (dto.stripeCustomerId !== undefined) {
      data.stripeCustomerId = dto.stripeCustomerId;
    }
    if (dto.isActive !== undefined) data.isActive = dto.isActive;
    if (dto.adminNotes !== undefined) data.adminNotes = dto.adminNotes;

    data.updatedAt = new Date();
    return data;
  }
}

Then simplify the service:

typescript
async update(userId: string, dto: UpdateProfileDto) {
  return this.prisma.user.update({
    where: { id: userId },
    data: UserMapper.toDatabaseUpdate(dto),
  });
}

This separation makes the mapping logic testable and reusable. You can verify that the mapper never touches sensitive fields without running the full integration suite.

Common mistakes

  • Spreading the DTO into Prisma without review: this.prisma.user.create({ data: { ...dto } }) defeats the purpose. Always list fields explicitly or use a dedicated mapper. We've seen teams rationalize this by saying "but the DTO already has validation," missing the point that validation is about format, not authorization.
  • Forgetting to validate on updates: Many teams validate on POST but skip PUT and PATCH. Use the same validation pattern for all mutation endpoints. Update endpoints are actually riskier because they often receive partial data and merge it with existing records.
  • Sharing a single DTO across endpoints: If your create and admin update endpoints use the same DTO, you've created a gap. Keep them separate and let the class-validator pipeline enforce boundaries. A shared DTO tempts shortcuts.
  • Disabling the ValidationPipe for convenience: Turning off whitelisting to avoid refactoring is a short-term win that becomes a long-term liability. The cost of handling unexpected fields is always less than the cost of a breach. When you skip validation, you're shifting the burden to your security team.
  • Trusting user input based on frontend validation: JavaScript validation is for UX. The backend must never assume the request came from your frontend. Always revalidate server-side. A curious user with curl or Postman can bypass your frontend entirely.
  • Not testing permission boundaries: Write tests that verify a non-admin user cannot set admin fields, even by sending them in the request. Without these tests, refactoring often accidentally opens new holes.
  • Allowing deeply nested objects without validation: If you accept an address: { street, city, country }, validate the structure and depth. Attackers can inject unexpected nested fields too.

Key takeaways

  • Never pass a request body directly to Prisma. Always route through a validated DTO.
  • Enable whitelisting on the ValidationPipe to automatically strip unexpected fields. Use environment-based strictness so development catches bugs while production stays resilient.
  • Create separate DTOs for different endpoints and user roles. A user DTO and an admin DTO serve different purposes and enforce different boundaries.
  • Map DTOs to database fields explicitly in your service layer. Don't spread objects; list the fields you intend to modify. This is your last defense against accidental exposure.
  • Log attempts to set forbidden fields for security monitoring and audit trails. Patterns of probing can indicate reconnaissance before an attack.
  • Test that non-admin requests cannot access admin endpoints or fields. Include this in your integration test suite and run it on every deploy.
  • Remember that mass assignment is a category of vulnerability that spans frameworks. These patterns work in Rails, Laravel, and FastAPI too. The principle is universal: control what the user can modify, not what they can't.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles