<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Veil In Sec</title>
    <description>The latest articles on DEV Community by Veil In Sec (@vis_softs).</description>
    <link>https://dev.to/vis_softs</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4128195%2F6ede1b0f-eb43-470e-a37d-074c0d81fdf7.png</url>
      <title>DEV Community: Veil In Sec</title>
      <link>https://dev.to/vis_softs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vis_softs"/>
    <language>en</language>
    <item>
      <title>Wiring Better Auth to Stripe in NestJS (the guide I wish existed)</title>
      <dc:creator>Veil In Sec</dc:creator>
      <pubDate>Fri, 25 Sep 2026 09:45:23 +0000</pubDate>
      <link>https://dev.to/vis_softs/wiring-better-auth-to-stripe-in-nestjs-the-guide-i-wish-existed-3jfl</link>
      <guid>https://dev.to/vis_softs/wiring-better-auth-to-stripe-in-nestjs-the-guide-i-wish-existed-3jfl</guid>
      <description>&lt;p&gt;**&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring Better Auth to Stripe in NestJS (the guide I wish existed)
&lt;/h2&gt;

&lt;p&gt;**&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trap: raw body parsing&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Fix it by registering the raw body parser only for the webhook route, before Nest's global body parser touches it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="s2"&gt;`ts
// main.ts
import { json, raw } from 'express';

app.use('/billing/webhook', raw({ type: 'application/json' }));
app.use(json());`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in the controller, read req.rawBody (or req.body if you used the raw middleware directly) rather than the parsed DTO:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="s2"&gt;`ts
@Post('billing/webhook')
async handleWebhook(@Req() req: RawBodyRequest&amp;lt;Request&amp;gt;, @Headers('stripe-signature') sig: string) {
  const event = this.stripe.webhooks.constructEvent(
    req.rawBody,
    sig,
    process.env.STRIPE_WEBHOOK_SECRET,
  );
  // handle event.type
}`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Mapping Stripe events to a Better Auth session&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="s2"&gt;`ts
const session = await stripe.checkout.sessions.create({
  customer_email: user.email,
  metadata: { userId: user.id }, // &amp;lt;-- your escape hatch back to Better Auth
  mode: 'subscription',
  line_items: [{ price: priceId, quantity: 1 }],
});`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gating a route by subscription status&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Once subscriptionStatus lives on the user record, a Better Auth session guard can read it like any other claim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="s2"&gt;`ts
@Injectable()
export class SubscriptionGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const { user } = context.switchToHttp().getRequest();
    return user?.subscriptionStatus === 'active';
  }
}`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No separate entitlements service, no second database round-trip at request time, it's just a field on the session user object.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The part that actually took a weekend&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I ended up packaging this exact setup, Better Auth, Stripe webhooks with the raw-body handling done correctly, and the subscription-status guard into &lt;a href="https://huskydev.top/"&gt;Huskydev&lt;/a&gt;, 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.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>stripe</category>
      <category>nestjs</category>
      <category>betterauth</category>
    </item>
  </channel>
</rss>
