import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
// rawBody: true preserves the exact bytes Stripe signed
const app = await NestFactory.create(AppModule, { rawBody: true });
app.enableShutdownHooks();
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
import {
BadRequestException,
CanActivate,
ExecutionContext,
Injectable,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import Stripe from 'stripe';
@Injectable()
export class StripeWebhookGuard implements CanActivate {
private readonly stripe: Stripe;
private readonly endpointSecret: string;
constructor(private readonly config: ConfigService) {
this.stripe = new Stripe(this.config.getOrThrow('STRIPE_SECRET_KEY'));
this.endpointSecret = this.config.getOrThrow('STRIPE_WEBHOOK_SECRET');
}
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<Request & { rawBody?: Buffer }>();
const signature = req.headers['stripe-signature'];
if (!signature || !req.rawBody) {
throw new BadRequestException('Missing signature or raw body');
}
try {
// Recomputes HMAC over rawBody and enforces the timestamp tolerance
const event = this.stripe.webhooks.constructEvent(
req.rawBody,
signature,
this.endpointSecret,
);
(req as any).stripeEvent = event;
return true;
} catch (err) {
throw new BadRequestException(`Invalid webhook signature: ${(err as Error).message}`);
}
}
}
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import Stripe from 'stripe';
export const StripeEvent = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): Stripe.Event => {
const req = ctx.switchToHttp().getRequest();
return req.stripeEvent as Stripe.Event;
},
);
import { Controller, HttpCode, Logger, Post, UseGuards } from '@nestjs/common';
import Stripe from 'stripe';
import { StripeWebhookGuard } from './stripe-webhook.guard';
import { StripeEvent } from './stripe-event.decorator';
import { BillingService } from './billing.service';
@Controller('webhooks/stripe')
export class StripeWebhookController {
private readonly logger = new Logger(StripeWebhookController.name);
constructor(private readonly billing: BillingService) {}
@Post()
@HttpCode(200)
@UseGuards(StripeWebhookGuard)
async handle(@StripeEvent() event: Stripe.Event): Promise<{ received: true }> {
switch (event.type) {
case 'invoice.paid':
await this.billing.markInvoicePaid(event.data.object as Stripe.Invoice);
break;
case 'customer.subscription.deleted':
await this.billing.cancelSubscription(event.data.object as Stripe.Subscription);
break;
default:
this.logger.debug(`Unhandled event type: ${event.type}`);
}
// Acknowledge fast so Stripe does not retry
return { received: true };
}
}
Webhook endpoints are public and unauthenticated by design, so the only proof that a request truly came from the provider is a cryptographic signature computed over the raw request body. This snippet moves that verification out of the controller and into a NestJS guard, so an unsigned or tampered payload is rejected before any handler logic runs. Doing the check in a guard keeps the controller focused on business logic and makes the security boundary explicit and reusable across multiple webhook routes.
The critical constraint is that signature verification must run against the exact bytes the provider signed, not a re-serialized object. In main.ts the app is created with rawBody: true, which tells NestJS to expose the untouched buffer on req.rawBody. Without this, the default JSON body parser would consume and re-encode the payload, and the HMAC would never match — a classic and frustrating pitfall.
The StripeWebhookGuard implements CanActivate and pulls the stripe-signature header plus the raw buffer off the request. Rather than hand-rolling HMAC comparison, it delegates to stripe.webhooks.constructEvent, which internally recomputes the signature, enforces a timestamp tolerance to prevent replay attacks, and throws on mismatch. The guard catches that error and raises a BadRequestException, so a bad signature returns 400 instead of leaking a stack trace. On success it stashes the verified Stripe.Event back onto the request via req.stripeEvent so the handler need not re-parse anything.
A small @StripeEvent() param decorator, defined with createParamDecorator, reads that stashed event, giving the controller a clean typed argument. In StripeWebhookController the guard is attached with @UseGuards(StripeWebhookGuard), and the handler receives an already-trusted event. Note the HttpCode(200) and the fast switch on event.type — providers retry on non-2xx responses, so acknowledging quickly and handling unknown types gracefully matters. The ConfigService supplies both the STRIPE_SECRET_KEY and the endpoint's STRIPE_WEBHOOK_SECRET, keeping secrets out of code. This pattern generalizes to any HMAC-signed webhook: verify the raw body in a guard, reject early, and pass a trusted object downstream.
Related snips
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
timestamp = request.headers.fetch('X-Signature-Timestamp')
signature = request.headers.fetch('X-Signature')
payload = request.raw_post
data = "#{timestamp}.#{payload}"
expected = OpenSSL::HMAC.hexdigest('SHA256', ENV.fetch('WEBHOOK_SECRET'), data)
HMAC signed API requests for webhook and partner integrity
package com.example.myapp
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
Dependency injection with Hilt
#!/usr/bin/env bash
set -euo pipefail
export VAULT_ADDR="https://vault.internal:8200"
export VAULT_TOKEN="${VAULT_TOKEN:?missing VAULT_TOKEN}"
Secrets management with environment isolation and Vault
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
import { randomBytes, createHash } from "crypto";
import jwt from "jsonwebtoken";
import { RefreshTokenStore } from "./store";
const ACCESS_SECRET = process.env.ACCESS_SECRET!;
const ACCESS_TTL = "15m";
JWT access + refresh token rotation (conceptual)
Share this code
Here's the card — post it anywhere.