How to Access Raw Body of Stripe Webhook in Nest.js

When integrating Stripe webhooks into my Nest.js application, I hit a roadblock: Stripe’s signature verification requires the raw, unmodified request body. But Nest.js (and Express under the hood) automatically parses incoming requests into JSON, altering the raw data and breaking the verification process. After hours of debugging, I found a solution. Here’s how I captured the raw body, verified webhooks securely, and added some extra robustness along the way.

The Problem: Why Raw Body Matters

Stripe uses a signature header to verify that webhook requests are genuine. This signature is computed using the raw payload of the request. If the body is parsed or modified (e.g., by Express middleware like body-parser), the signature check fails, leaving your app vulnerable to forged events.

Nest.js’s default behavior is to parse the request body into JSON, which strips away the raw data. My challenge was to intercept and preserve the raw payload before any parsing occurred.

Middleware to Capture Raw Body

I created a custom middleware to capture the raw body and attach it to the request object.

// raw-body.middleware.ts
import { Request, Response, NextFunction } from 'express';

/**
* Middleware to capture the raw body of the request.
* It listens for data events to accumulate the raw payload,
* then attaches the result to req.rawBody.
*/
export function addRawBody(req: Request, res: Response, next: NextFunction) {
// Set the encoding to ensure we get a string
req.setEncoding('utf8');

let data = '';

req.on('data', (chunk) => {
data += chunk;
});

req.on('end', () => {
// Attach the raw data to the request object
(req as any).rawBody = data;
next();
});
}

What I Did Here

  • Setting the Encoding: I ensured the request is read as a UTF-8 string.
  • Listening for Data: I listened to the data event to collect the incoming chunks.
  • Completion with end: Once all data is received, I attach it to req.rawBody and call next() to continue the request lifecycle.

Using the Middleware in the Controllers

I created two controllers: one for handling the Stripe webhooks and another for testing purposes. This allowed me to verify that the raw body was being captured correctly and used where needed.

Stripe Webhook Controller

This controller handles POST requests from Stripe, and it utilizes the raw body for signature verification.

// stripe.controller.ts
import { Controller, Post, Req } from '@nestjs/common';
import { Request } from 'express';

@Controller('subscriptions/stripe')
export class StripeController {
/**
* Endpoint to handle Stripe webhooks.
* Uses the raw body from the middleware to verify the signature.
*/
@Post()
handleStripeWebhook(@Req() req: Request) {
const rawBody = (req as any).rawBody;
console.log('Stripe webhook raw body:', rawBody);

// Here you would typically call:
// const event = stripe.webhooks.constructEvent(rawBody, signature, endpointSecret);
// and then process the event.

return { message: 'Stripe webhook received' };
}
}

Test Controller for Practice

I also added a test controller to echo back the raw body, helping me to ensure that my middleware works as expected on different routes.

// test.controller.ts
import { Controller, Post, Req } from '@nestjs/common';
import { Request } from 'express';

@Controller('test')
export class TestController {
/**
* A test endpoint to show how the raw body can be accessed.
*/
@Post('raw')
handleTestRawBody(@Req() req: Request) {
const rawBody = (req as any).rawBody;
console.log('Test raw body:', rawBody);
return { message: 'Test endpoint received raw body', rawBody };
}
}

Combining Everything in the Module

To put it all together, I applied the middleware to the specific routes that require the raw body. This avoids interfering with other parts of my application that rely on Nest.js’s built-in parsers.

// subscription.module.ts
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { StripeController } from './stripe.controller';
import { TestController } from './test.controller';
import { addRawBody } from './raw-body.middleware';

@Module({
controllers: [StripeController, TestController],
})
export class SubscriptionModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
// Apply the addRawBody middleware only to routes that need the raw body.
consumer
.apply(addRawBody)
.forRoutes('subscriptions/stripe', 'test/raw');
}
}

Extra Functionality for Practice

While solving my problem, I decided to add some extra functionality to enhance the debugging process and make the middleware more robust.

Extended Logging Middleware

I created an additional middleware that logs the incoming raw body. This helped me confirm that the data was being captured correctly.

// logging.middleware.ts
import { Request, Response, NextFunction } from 'express';

export function logRawBody(req: Request, res: Response, next: NextFunction) {
console.log('Incoming raw body:', (req as any).rawBody);
next();
}

I then applied it alongside the addRawBody middleware:

// subscription.module.ts (modified)
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { StripeController } from './stripe.controller';
import { TestController } from './test.controller';
import { addRawBody } from './raw-body.middleware';
import { logRawBody } from './logging.middleware';

@Module({
controllers: [StripeController, TestController],
})
export class SubscriptionModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(addRawBody, logRawBody)
.forRoutes('subscriptions/stripe', 'test/raw');
}
}

Error Handling Enhancements

To handle scenarios where the request might hang or not complete, I added a timeout and error listeners to the middleware. This ensures that the request does not get stuck indefinitely.

 addRawBody(req: Request, res: Response, next: NextFunction) {
req.setEncoding('utf8');
let data = '';

// Set a timeout to handle hanging requests
const timeout = setTimeout(() => {
console.error('Request timed out while reading raw body');
next(new Error('Request timed out'));
}, 5000); // 5 seconds timeout

req.on('data', (chunk) => {
data += chunk;
});

req.on('error', (err) => {
clearTimeout(timeout);
next(err);
});

req.on('end', () => {
clearTimeout(timeout);
(req as any).rawBody = data;
next();
});
}

Final Thoughts

I learned that handling raw request data in Nest.js requires careful planning, especially when dealing with external services like Stripe that demand an unaltered payload for security checks. By writing custom middleware, I was able to capture the raw body and apply it selectively only where it was needed. Adding extended logging and error handling further solidified the solution, ensuring a more robust and debuggable implementation.

Related blog posts