STRIPE STARTER · VALIDATION (ZOD)
Validation (Zod)
Every piece of data that enters the system from outside passes through a Zod schema in src/lib/validation.ts. Centralized, typed, and the open-redirect vectors are already plugged.
External input is anywhere a user, URL, or third party can influence the value: form data, search params, server action arguments, webhook headers. Every one of those goes through a schema in src/lib/validation.ts. Stripe webhook payloads are the only exception — they're verified by HMAC signature instead, because the raw body must be preserved.
What's already validated
// src/lib/validation.ts
stripeSessionIdSchema // cs_test_xxx or cs_live_xxx
stripeCustomerIdSchema // cus_xxx (min 8 chars)
stripePriceIdSchema // price_xxx
checkoutInputSchema // { priceId }
portalInputSchema // { customerId, returnPath? }
successPageParamsSchema // { session_id }Using a schema (the safeParse pattern)
import { checkoutInputSchema } from "@/lib/validation";
// ALWAYS safeParse, never parse. Throwing belongs in tests, not user flows.
const result = checkoutInputSchema.safeParse({ priceId });
if (!result.success) {
return { success: false, error: "Invalid price ID format" };
}
// result.data is now typed and trusted.
const { priceId: validPriceId } = result.data;The open-redirect prevention pattern
The portal's returnPath is the kind of thing attackers love — pass https://evil.com and you've got an open redirect off your domain. Our schema requires it to start with / AND match a strict regex.
returnPath: z
.string()
.startsWith("/", "Return path must be a relative URL")
.regex(/^\/[a-zA-Z0-9\-_/]*$/, "Return path contains invalid characters")
.optional(),Adding a new schema
- Define it in src/lib/validation.ts (don't validate inline — centralize so the rules are auditable).
- Export the inferred type: export type FooInput = z.infer<typeof fooInputSchema>.
- Use safeParse at the trust boundary — Server Action entry, page param parse, webhook header check.
- Add tests in src/lib/validation.test.ts. There are already 27 examples to copy from.
Never trust raw URL params or form data
It's tempting to do const id = searchParams.session_id ?? "" and call it a day. Don't. A malformed session ID hits stripe.checkout.sessions.retrieve(), Stripe throws, your error boundary catches a stack trace it didn't need. Pass everything through the right schema.
Stripe IDs are validated by PREFIX, not by existence
stripeCustomerIdSchema confirms the string starts with cus_, NOT that the customer exists in your Stripe account. That second check happens when you actually call the Stripe API. The schema catches typos and obvious attacks — the API call catches everything else.