Most Stripe-and-Auth.js tutorials solve the wrong problem first. They show a "Manage Billing" button before they show what happens when your app's idea of who is logged in disagrees with Stripe's idea of who is paying. A production SaaS customer portal is that handshake, and it breaks in specific, reproducible ways. I've shipped Stripe billing on two live products, BookBed and Callidus, neither one built on Auth.js. This is the Auth.js-specific version of the same pattern: session lookup, customer mapping, portal session, and the redirect back that quietly logs people out if you get one cookie attribute wrong.
Key Takeaways

- Creating a portal session is one API call: POST to
/v1/billing_portal/sessionswith justcustomerandreturn_url. - Usage-based subscriptions are portal-restricted to cancel-only. Customers can't view metered usage or change plans inside Stripe's hosted UI.
- Auth.js gives you the session. Stripe gives you the customer. The mapping between the two lives in your own database, not in either library.
- A cookie set with
SameSite=strictcan silently drop your user's session the instant Stripe redirects back from the portal, and it will pass every manual test you run. - Any trial in progress ends immediately, not at renewal, the moment a customer changes plans through the portal.
Why Do Most SaaS Teams Reach for the Stripe Customer Portal First?

Because it hands off subscription cancellation and payment-method updates to Stripe's own hosted page in one API call, with invoice history included automatically.
Rolling a custom billing UI means building payment-method forms, invoice tables, plan-change previews, and proration math yourself, which covers a fair chunk of what a full Stripe integration actually involves on the billing side alone, then keeping all of it in sync with whatever Stripe's webhooks report. The portal skips that layer entirely. You create a session server-side with stripe.billingPortal.sessions.create, passing the Stripe customer ID and a return_url, and Stripe hands back a short-lived URL you redirect the browser to (the full parameter contract lives in Stripe's own docs). For a v1 SaaS trying to ship a working billing settings page in a day instead of a sprint, that trade is usually the right one.
You've felt the alternative. Three weeks into a billing rebuild, still hand-rolling a plan-change preview with proration math you're not fully sure is right.
Stripe Customer Portal vs Custom Billing UI: Which Wins for a B2B SaaS?

For most B2B SaaS teams the portal wins by default, and only usage-based billing or heavy brand control should pull you toward a custom UI.
| Factor | Stripe Customer Portal | Custom UI (Elements + your own screens) | |---|---|---| | Build time | Hours | Days to weeks | | Usage-based billing | Cancel only, no usage display | Full control, but you build the dashboard | | Brand control | Logo, color, headline only | Full | | Webhook dependency | Still required for state sync | Still required, plus more surface area | | Maintenance | Stripe ships changes | You track every Stripe API change yourself |
The row people underrate is webhook dependency. Picking the portal doesn't remove the need for a Stripe webhook handler; Stripe writes the truth, and your handler still has to read it and update your database. What the portal removes is the UI layer sitting on top of that truth, not the truth itself. Get that wrong and the cost shows up as revenue, not bugs: Stripe's own 2023 survey of 1,500 subscription business leaders found 41% saw involuntary churn increase, and a third of those still weren't using any automated recovery tooling, which is exactly the kind of thing a webhook-synced customer record is supposed to catch.
The Trial-Ends-Immediately Trap
If a customer is mid-trial and changes plans inside the portal, the trial ends the moment they hit save, immediately, not at the next renewal. Stripe documents this explicitly, and it's the single most common support ticket you'll generate from exposing the plan-change flow to trial users without a warning modal in front of it. Gate that button, or budget for the tickets.
How Do You Wire the Portal Session to Auth.js in Next.js?
You look up the signed-in user's Stripe customer ID from your own database, then create a portal session server-side and redirect.
- Get the session. Call
auth()from your Auth.js config inside a Route Handler to confirm the request is actually signed in. - Look up the Stripe customer ID. Auth.js gives you a user ID; your own
usersorsubscriptionstable maps that ID to astripe_customer_id. Stripe never sees your Auth.js session, so this mapping is the only bridge between the two. - Create the portal session. Call
stripe.billingPortal.sessions.create({ customer, return_url })server-side, never in a client component, since it needs your secret key. - Redirect the browser. Send the user to the returned
url. It's single-use and expires quickly, so don't cache it or reuse it across requests. - Handle the return. When Stripe sends the user back to
return_url, re-validate the Auth.js session before rendering anything billing-related. The redirect itself proves nothing about who's signed in on this tab.
None of this is Auth.js-specific in the Stripe sense. Stripe has no idea which auth library you're running. What Auth.js buys you is a stable auth() call you can drop into any Route Handler without re-deriving session logic every time you add a new billing action.
The Redirect Back From Stripe Can Silently Log Your User Out
Wednesday afternoon, a user finishes updating their card in the portal, gets redirected back to the dashboard, and lands on a page that treats them as signed out. Support ticket, confused user, nothing useful in the logs.
The cause sat in one cookie attribute. When Stripe redirects back to your app, the browser treats it as a cross-site navigation, and a session cookie set with SameSite=strict gets dropped on exactly that hop. A long-running Next.js discussion thread traces this to the same root cause across dozens of reports: refreshing the page fixes it, because a same-origin request sends the cookie fine, which is exactly why it survives manual testing and only shows up once real users hit it. The fix is SameSite=lax on the session cookie, not anything on Stripe's side at all.
Actually, the first time I saw this reported I assumed it was a webhook timing issue. It wasn't. It's a browser default fighting a payment provider's redirect pattern, and Stripe is just the messenger standing at the door when the alarm goes off.
Where Does the Portal Fall Short for Usage-Based Billing?
It lets customers cancel a metered subscription but shows no usage data and blocks any plan change on it entirely.
Stripe's own customer-management docs are explicit about this: usage-based subscriptions can be cancelled from the portal, but customers can't upgrade, downgrade, or see what they've actually used this period. If your pricing has any metered component, API calls, fluctuating seats, storage tiers, you're building a usage dashboard yourself regardless of whether the portal handles the rest of billing. That gap isn't really a portal shortcoming so much as a sign that usage-based SaaS needs its own billing surface, with the portal handling only what stays static: card on file and invoice history.
One more thing worth checking before you build this today. Stripe's Accounts v2 API went GA for new Connect platforms in late 2025, and Stripe's own migration notes now recommend the v2 path for new Connect integrations instead of the older Accounts v1 plus Customers v1 split. Callidus runs on Stripe Connect, and Connect is exactly the case where this newer object model matters most: fewer duplicate identities, one KYC pass instead of two. If you're building anything Connect-shaped on top of this portal pattern, check which object model your integration actually expects before copying code from an older tutorial.
What Auth.js Actually Buys You Over Rolling Your Own Session Layer
Auth.js doesn't touch Stripe at all, and that's the point. It owns exactly one job: giving you a signed, verifiable session on every request, so the auth() call at the top of your portal-session Route Handler is the same one guarding every other protected route in the app. On BookBed I solved the equivalent problem with Firebase Auth instead, mapping the user ID to a Stripe customer ID in Firestore rather than Postgres, and the webhook-driven billing state that keeps that mapping honest is the same pattern regardless of which auth library sits underneath it: Stripe writes, your database reflects, your session layer reads. Swap Auth.js for Firebase Auth or Supabase Auth and the shape barely changes. If you're already committed to a Supabase-plus-Stripe stack, the portal-session route looks nearly identical, just with getUser() from Supabase instead of auth() from Auth.js.
None of this billing work happens in isolation from the rest of the SaaS MVP stack you've already picked. The auth library is a small piece. The webhook handler underneath it is the piece that actually keeps your customer portal honest, trial after trial, redirect after redirect.
Wire up the portal session route first, before you touch a single billing screen. Then go check your session cookie's SameSite attribute. Is it lax?
