Weekendstack

Blog

How to Build a Safer Next.js + Firebase + Stripe SaaS Starter (2026): Webhooks, Auth, and CSP

August 18, 2026 · 11 min read

If you have ever rebuilt the same SaaS foundation and lost days to webhook retries, auth race conditions, or a Content-Security-Policy that silently breaks Google sign-in and Stripe.js, you are not alone. The gap between a demo and a production-ready starter is almost never the feature work — it is these eight boring, load-bearing decisions. Get them right once and every feature you build after sits on solid ground. Get them wrong and you find out in production, after money has moved.

Who this is for: developers building a Next.js + Firebase + Stripe SaaS who want a production baseline instead of another half-working boilerplate.

Step 1: Start with server-verified auth, not client-only auth

The Firebase browser SDK gives you a user object in React. That is not the same thing as your server knowing who is calling. Server Components, API routes, and admin checks cannot read browser state, so if the client is your only source of identity you end up passing uids around in request bodies — which is to say, letting the browser claim who it is.

The fix is a server-side session cookie. Exchange the Firebase ID token for an httpOnly cookie once, then every server read verifies that cookie:

// POST /api/auth/session — mint the cookie from a verified ID token
const decoded = await adminAuth().verifyIdToken(idToken);
const sessionCookie = await adminAuth().createSessionCookie(idToken, {
  expiresIn: SESSION_TTL_MS,
});

cookieStore.set(SESSION_COOKIE, sessionCookie, {
  httpOnly: true,
  secure: process.env.NODE_ENV === "production",
  sameSite: "lax",
  path: "/",
  maxAge: SESSION_TTL_MS / 1000,
});

Now a Server Component can call requireUser() and get a verified uid with no client round trip and no flash of the wrong state. Client-side route guards still have a job — they are good UX — but they are not enforcement. Every protected read and every write must re-check server-side, because a client gate is a suggestion the browser is free to ignore.

Keep secure conditional on production, or the cookie is never set over plain http and local development mysteriously never logs anyone in.

Step 2: Make the auth flow resilient to slow profile reads

Auth is two problems wearing one coat: identity (who is this?) and profile readiness (do I have their data yet?). Most blank-screen bugs come from conflating them. The user is authenticated in 200ms, but the app renders a spinner until a Firestore profile document resolves — and if that read fails, or the document does not exist yet because it is created by the same request that is being awaited, the spinner is forever.

Two rules. First, gate rendering on identity, not on profile: render the shell as soon as you know who the user is, and let profile-dependent widgets show their own loading state. Second, every await that blocks first paint needs a timeout and a fallback — an app that renders with partial data beats an app that hangs.

A related trap in React 19: do not copy external state into component state inside an effect. It is a race by construction, and react-hooks/set-state-in-effect now flags it as an error. Read external state with useSyncExternalStore, or move the update into the callback that actually observes the change.

Step 3: Handle Stripe webhooks as if they will retry — because they will

Stripe retries any non-2xx response, and can deliver the same event twice even after a success. If your handler is not idempotent, a retry double-grants access, double-increments a counter, or sends a second confirmation email. Assume every handler runs more than once.

Idempotency means recording the event id before doing the work, and skipping anything you have already seen:

if (await isEventProcessed(event.id)) {
  return new Response("ok", { status: 200 }); // already handled
}
await applyPurchase(event);       // atomic, in a transaction
await markEventProcessed(event.id);

Three details that bite. The signature check needs the raw request body — read await req.text(), never a parsed JSON object, because Stripe signs the exact bytes it sent. The route must run on the Node.js runtime for the same reason. And you should return 500 only for retryable failures: an unmapped price id or a missing user should return 200 with a log line, since retrying a mapping mistake forever just fills your logs while Stripe eventually gives up.

One Firestore-specific gotcha, because it produces a spectacular failure mode: a transaction must do all its reads before any write. Collapse an idempotency check and its write into one read-then-write helper and the transaction throws at runtime — every purchase 500s while Stripe shows the payment as successful. That is why the check above is two functions and not one.

Step 4: Store subscription state in your database, not in your UI

The success page is not evidence of payment. The user can close the tab before the redirect, the redirect can fail, and anyone can navigate directly to /success. Any code path that grants access from the browser is a code path that grants access to people who did not pay.

The webhook is the only signal that is signed, retried, and independent of the browser — so it is the only writer of billing state. The frontend reads a durable record; it never decides one. Concretely: exactly one module writes entitlements, and it is reachable only from the signature-verified webhook.

A stronger version of the same idea is to keep a ledger rather than a flag. Store one row per payment, and derive access as a function of the paid rows. Recomputing beats toggling because refunds, partial refunds, disputes, and out-of-order event delivery all converge to the right answer instead of leaving a boolean stuck in the wrong position.

Step 5: Lock down uploads and storage from the start

Proxying file uploads through your own server burns function time and memory for no benefit. Issue a presigned URL and let the browser PUT straight to object storage — S3, R2, whatever you use.

The security rule is short: the key comes from the verified session, never from the client. Scope every upload under users/{uid}/…using the uid your server derived from the session cookie. If the client sends the path, the client can send someone else's path.

For paid assets, never put the bucket behind a permanent public URL. Presign the download every time, gated on the entitlement check, so the file is reachable for minutes rather than forever at a guessable address.

Step 6: Treat Content-Security-Policy as part of the product

CSP is where this stack most often breaks after deploy, because local development frequently runs without the header. Google sign-in opens a popup on a Google domain, Stripe.js loads a script and mounts an iframe, and Firebase talks to several different hosts. A policy that forgets any one of them produces a silent, console-only failure on your checkout page.

Directives this stack actually needs:

Also set frame-ancestors 'none' to block clickjacking, and note that X-Frame-Options does not replace it. Test sign-in and a real checkout on a preview deployment with the production headers applied — not on localhost — before you ship.

Step 7: Gate admin features on the server, in layers

Hiding an admin link is not security. Anyone can type the URL, and the data lives behind an API route that does not care what your navigation rendered. Authorization belongs on the server, checked independently at every entrance.

Four layers, each of which must hold on its own:

  1. An allowlist or role claim resolved server-side.
  2. The admin page itself, as a Server Component, refusing to render for a non-admin.
  3. Every admin API route calling its own requireAdmin() — routes are independently reachable, so the page's check does not protect them.
  4. Database rules denying direct client access to privileged collections.

Prefer notFound()over a 403 for the admin surface, and keep the 404 copy neutral. A page that says “forbidden” confirms that the page exists, which is information you did not need to give away.

Step 8: Start from a foundation that already bakes this in

None of the above is hard in isolation. It is hard because all eight have to be right at the same time before a single paid signup works end-to-end, and because each one fails quietly in a different place — the browser console, the Stripe dashboard, a Firestore rules log, or nowhere at all.

That is the case for starting from a codebase where they are already wired. Weekendstack ships this exact stack — Next.js App Router, Firebase Auth with server-verified session cookies, Firestore with locked-down rules, Stripe with an idempotent ledger-based webhook, R2 presigned uploads, transactional email, and a server-gated admin dashboard — from a single .env.local. It also comes with an AI prompt pack written against that same architecture, so the features you generate next match the foundation instead of fighting it.

The five mistakes that cause most of the pain

Want the foundation done and the edge cases already handled? See Weekendstack. Related reading: the SaaS security checklist, Supabase vs Firebase for a SaaS MVP, and whether Next.js is a good fit for SaaS.

Frequently asked

Why do Stripe webhooks need idempotency?
Stripe retries any non-2xx response and can deliver the same event twice even after a success. Without an idempotency check, a retry double-grants access, double-increments counters, or sends duplicate emails. Record the event id and skip events you have already processed, and do the grant inside a transaction so it applies exactly once.
Why use server-side session cookies with Firebase Auth?
The Firebase browser SDK only tells the client who the user is. Server Components, API routes, and admin checks cannot read browser state, so without a session cookie you end up trusting a uid the browser sent. Exchange the ID token for an httpOnly session cookie once, then verify that cookie on every server-side read. Keep the secure flag conditional on production or local http development will never log anyone in.
What causes the blank-screen auth problem in Next.js?
The app gates its entire render on a Firestore profile read or an auth-ready check that never resolves — often because the profile document is created by the very request being awaited. Separate identity from profile readiness: render the shell as soon as you know who the user is, give profile-dependent widgets their own loading state, and put a timeout and fallback on anything that blocks first paint.
What CSP directives does a Next.js + Firebase + Stripe app need?
script-src must allow js.stripe.com, apis.google.com and www.gstatic.com; frame-src must allow js.stripe.com, hooks.stripe.com and your Firebase auth domain, since Stripe Elements and the Google sign-in handler are both iframes; connect-src must allow api.stripe.com, *.googleapis.com and *.firebaseio.com; img-src needs *.stripe.com and data:. Add frame-ancestors 'none' for clickjacking protection, and test on a preview deployment with production headers — CSP mistakes usually appear only after deploy.
Can I grant access from the Stripe success page instead of a webhook?
No. The user can close the tab before the redirect, the redirect can fail, and anyone can navigate to the success URL directly. The webhook is the only signal that is signed, retried, and independent of the browser, so it must be the only writer of billing state. Better still, keep a ledger of paid rows and derive access from it, so refunds and out-of-order events converge instead of leaving a boolean stuck.
Is hiding the admin link enough to protect an admin dashboard?
No. Hiding a link is not security — the URL is typeable and the underlying API routes are independently reachable. Use layered server-side checks: a role or allowlist resolved on the server, the admin page refusing to render for non-admins, every admin API route calling its own requireAdmin(), and database rules denying direct client access. Prefer a neutral 404 over a 403 so you do not confirm the page exists.
Do I really need a SaaS starter kit for this?
If you have built the same auth, billing, storage, and email plumbing more than once, the value is not raw speed — it is not re-encountering the same edge cases. These eight decisions all have to be correct simultaneously before one paid signup works end to end, and each fails quietly in a different place.

Build yours this weekend

Weekendstack is the production-ready codebase plus the prompt pack that makes your AI coding agent ship real features instead of guessing.

See pricing