**
Wiring Better Auth to Stripe in NestJS (the guide I wish existed)
**
Every NestJS + Stripe tutorial I found last month assumed you're using Passport or a hand-rolled JWT setup. None of them touched Better Auth. And the one integration that does exist-thallesp/nestjs-better-auth—doesn't cover billing at all.
So here's the part nobody wrote down: getting a Stripe subscription's status to actually reach a Better Auth session, without your webhook handler silently failing on day one.
The trap: raw body parsing
Stripe signs every webhook payload, and verifying that signature requires the raw, unparsed request body. NestJS's global ValidationPipe and Express's default JSON parser will happily consume and reformat that body before your handler ever sees it, and stripe.webhooks.constructEvent() will throw No signatures found matching the expected signature for the payload with no obvious cause.
Fix it by registering the raw body parser only for the webhook route, before Nest's global body parser touches it:
`ts
// main.ts
import { json, raw } from 'express';
app.use('/billing/webhook', raw({ type: 'application/json' }));
app.use(json());`
Then in the controller, read req.rawBody (or req.body if you used the raw middleware directly) rather than the parsed DTO:
`ts
@Post('billing/webhook')
async handleWebhook(@Req() req: RawBodyRequest<Request>, @Headers('stripe-signature') sig: string) {
const event = this.stripe.webhooks.constructEvent(
req.rawBody,
sig,
process.env.STRIPE_WEBHOOK_SECRET,
);
// handle event.type
}`
Mapping Stripe events to a Better Auth session
Better Auth stores its own user and session records; Stripe doesn't know either exists. The connective tissue is a stripe.customerId column on your user table, set once at checkout:
`ts
const session = await stripe.checkout.sessions.create({
customer_email: user.email,
metadata: { userId: user.id }, // <-- your escape hatch back to Better Auth
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
});`
On checkout.session.completed, pull metadata. The userId back out and write the Stripe customer and subscription IDs onto that user. On customer.subscription.updated and .deleted, update the subscriptionStatus field. Don't trust the frontend to tell you the plan status, always resolve it server-side from the webhook, since a client-reported "I'm subscribed now" is trivially spoofable.
Gating a route by subscription status
Once subscriptionStatus lives on the user record, a Better Auth session guard can read it like any other claim:
`ts
@Injectable()
export class SubscriptionGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const { user } = context.switchToHttp().getRequest();
return user?.subscriptionStatus === 'active';
}
}`
No separate entitlements service, no second database round-trip at request time, it's just a field on the session user object.
The part that actually took a weekend
None of the above is hard in isolation. What ate the time was the ordering: raw-body middleware has to be registered before the global JSON parser, the webhook secret has to match the specific endpoint (test vs. live mode uses different secrets), and Stripe retries failed webhooks for up to three days, so an idempotency check on the event ID matters more than it sounds like it should, or you'll double-grant access on a retry.
I ended up packaging this exact setup, Better Auth, Stripe webhooks with the raw-body handling done correctly, and the subscription-status guard into Huskydev, a Next.js + NestJS starter I built. Not required reading to use the code above; it just saves you the weekend if you'd rather skip it.
Top comments (0)