Skip to content
ExponaBLOG
NESTJS · DAY 061 / 100

rawBody without breaking JSON parsing: a practical guide

Keep normal JSON parsing and still verify signatures.

Expona Admin8 min read

Day 61: rawBody without breaking JSON parsing

Webhook verification is one of those problems that feels simple until you realize you need the raw request body to compute a signature, but your framework has already parsed and buffered it. In NestJS, you can keep rawBody without breaking JSON parsing by configuring your middleware strategically and only applying raw body capture to routes that need it. This approach keeps your webhook routes cryptographically secure while leaving the rest of your API endpoints fast and normal.

The core problem: raw body conflicts with JSON middleware

When Express (NestJS's underlying framework) applies the built-in JSON middleware, it consumes the incoming stream and stores the parsed object in req.body. Once that stream is consumed, you can't read it again to get the raw bytes. Yet webhook signatures—whether from Stripe, Twilio, or your own webhooks—depend on hashing the exact bytes that arrived on the wire.

The naive fix is to enable rawBody: true globally via NestFactory.create(), but then every route gets a buffer and you lose automatic JSON parsing everywhere. You'd have to manually parse JSON in every controller, which defeats the point of using a framework.

The better approach is surgical: preserve normal JSON parsing for most routes while capturing raw bodies only where you need them—typically your webhook endpoints.

rawBody: true keeps both parsed and raw bodies

NestJS offers a rawBody option in the application factory that does exactly what the name suggests: it stores the raw request body before parsing. The key insight is that when you enable this, Express doesn't discard the raw bytes; instead, it makes them available via req.rawBody while still parsing JSON normally into req.body.

Here's how to set it up in your main.ts:

typescript
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useRawBody(true);

  await app.listen(3000);
}

bootstrap();

Now every request arriving at your application will have both req.body (parsed JSON) and req.rawBody (buffer). The performance impact is minimal because you're just keeping the bytes in memory; the parsing still happens automatically for normal routes.

Only webhook routes need the raw buffer

Even though you've enabled rawBody globally, you only actually use req.rawBody in specific routes—your webhook handlers. All other controllers continue working as before, receiving nicely parsed JSON objects.

Let me show you a practical example. Imagine you're handling webhooks from an external payment provider:

typescript
import { Controller, Post, Req, BadRequestException } from '@nestjs/common';
import { Request } from 'express';
import * as crypto from 'crypto';

interface RawBodyRequest extends Request {
  rawBody: Buffer;
}

@Controller('webhooks')
export class WebhookController {
  constructor(private webhookService: WebhookService) {}

  @Post('payment')
  async handlePaymentWebhook(@Req() req: RawBodyRequest) {
    // This route uses rawBody
    const signature = req.headers['x-signature'] as string;
    const rawBody = req.rawBody;

    // Verify signature (we'll cover this next section)
    const isValid = this.webhookService.verifySignature(
      rawBody,
      signature,
    );

    if (!isValid) {
      throw new BadRequestException('Invalid signature');
    }

    // Now that we've verified, we can trust req.body
    const payload = req.body;
    await this.webhookService.processPayment(payload);

    return { success: true };
  }
}

@Controller('orders')
export class OrderController {
  constructor(private orderService: OrderService) {}

  @Post()
  async createOrder(@Body() createOrderDto: CreateOrderDto) {
    // This route doesn't care about rawBody
    // It just gets automatic JSON parsing
    return this.orderService.create(createOrderDto);
  }
}

Notice that the webhook handler explicitly requests req.rawBody, while the normal order endpoint just uses the standard @Body() decorator. Both work perfectly: one gets cryptographic security, the other gets framework convenience.

Verify before trusting any field

Receiving rawBody is pointless if you don't actually verify the signature. Never trust a webhook payload just because it arrived at your endpoint. Always compute the expected signature and compare it to the one in the request headers.

Here's a realistic webhook service that handles signature verification:

typescript
import { Injectable } from '@nestjs/common';
import * as crypto from 'crypto';

@Injectable()
export class WebhookService {
  private readonly webhookSecret = process.env.WEBHOOK_SECRET;

  verifySignature(rawBody: Buffer, signature: string): boolean {
    // Most webhook providers use HMAC-SHA256
    // The raw body gets hashed along with your secret
    const computed = crypto
      .createHmac('sha256', this.webhookSecret)
      .update(rawBody)
      .digest('hex');

    // Use constant-time comparison to prevent timing attacks
    return crypto.timingSafeEqual(
      Buffer.from(computed),
      Buffer.from(signature),
    );
  }

  async processPayment(payload: any) {
    // Only reach here if signature was valid
    // Now you can safely process the webhook
    console.log('Processing payment:', payload.transactionId);
    // update database, trigger business logic, etc.
  }
}

A few critical details here:

  • Hash the raw bytes, not the parsed JSON. The provider signed the exact bytes they sent; JSON parsing can normalize whitespace differently, breaking the verification.
  • Use constant-time comparison. The crypto.timingSafeEqual function prevents timing attacks where an attacker guesses the signature byte by byte based on how long the comparison takes.
  • Store your webhook secret securely. Use environment variables, never hardcode it.

Reject requests older than a few minutes

Signature verification ensures the request came from the provider you trust, but it doesn't prevent replay attacks. An attacker could capture a valid webhook, obtain the signature, and send it again hours or days later to trigger duplicate payments or duplicate orders.

Combat this by including a timestamp in the webhook and rejecting anything that's too old. Most legitimate webhook providers include a timestamp header:

typescript
import { Controller, Post, Req, BadRequestException } from '@nestjs/common';
import { Request } from 'express';

interface RawBodyRequest extends Request {
  rawBody: Buffer;
}

@Controller('webhooks')
export class WebhookController {
  private readonly maxAgeSeconds = 300; // 5 minutes

  constructor(private webhookService: WebhookService) {}

  @Post('payment')
  async handlePaymentWebhook(@Req() req: RawBodyRequest) {
    const signature = req.headers['x-signature'] as string;
    const timestamp = req.headers['x-timestamp'] as string;
    const rawBody = req.rawBody;

    // Check timestamp first, before even verifying signature
    this.validateTimestamp(timestamp);

    // Verify signature
    const isValid = this.webhookService.verifySignature(
      rawBody,
      signature,
      timestamp, // Some providers include timestamp in the signed payload
    );

    if (!isValid) {
      throw new BadRequestException('Invalid signature');
    }

    const payload = req.body;
    await this.webhookService.processPayment(payload);

    return { success: true };
  }

  private validateTimestamp(timestamp: string) {
    if (!timestamp) {
      throw new BadRequestException('Missing timestamp');
    }

    const requestTime = parseInt(timestamp, 10);
    const now = Math.floor(Date.now() / 1000);
    const age = now - requestTime;

    if (age < 0) {
      // Clock skew: request timestamp is in the future
      throw new BadRequestException('Request timestamp is in the future');
    }

    if (age > this.maxAgeSeconds) {
      throw new BadRequestException('Request is too old');
    }
  }
}

This simple check stops replayed webhooks cold. If someone tries to send an old webhook payload again, the timestamp validation fails before you even do crypto work. Set the threshold based on your network conditions; 5 minutes is typical for cloud providers with good time sync.

Common mistakes

  • Forgetting to type the request. TypeScript doesn't know about rawBody unless you extend the Express Request interface. You'll get a compiler error and might resort to any, losing type safety.
  • Hashing the parsed JSON instead of the raw buffer. Your signature will never match because JSON normalization (whitespace, key order) might differ from what the provider sent.
  • Using normal string comparison instead of timingSafeEqual. A timing attack leaks information about which bytes of the signature are correct, letting attackers brute-force it faster.
  • Enabling rawBody globally and forgetting the performance cost. Every request now keeps its body in memory twice (raw and parsed). For high-traffic APIs, this matters. Use middleware to apply it only to webhook routes if you're concerned.
  • Trusting the timestamp without verifying the signature. Someone could forge a fresh timestamp. Always do both checks.

Key takeaways

  • app.useRawBody(true) in NestJS gives you both req.body (parsed) and req.rawBody (buffer) on every request, letting you keep normal JSON parsing while accessing raw bytes for signature verification.
  • Only webhook routes need to actually use rawBody; the rest of your API works unchanged, enjoying automatic parsing and validation.
  • Always verify the signature by hashing rawBody and comparing with constant-time comparison before processing any webhook data.
  • Add timestamp validation to prevent replay attacks, rejecting requests older than a few minutes.
  • Type-extend the Express Request interface so TypeScript knows about rawBody, keeping your code safe and readable.
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