Security

Password hashing, JWT sessions, webhook signature verification, Zod validation, RBAC, and rate limiting.

Password Hashing

Passwords are hashed with bcryptjs at cost factor 12. Plaintext passwords are never stored or logged.

import bcrypt from "bcryptjs"

const hash = await bcrypt.hash(password, 12)
const isValid = await bcrypt.compare(inputPassword, storedHash)

API routes never return passwordHash — always use select to exclude it explicitly.


JWT Sessions

LaunchFst uses NextAuth v5 with the JWT strategy — no database sessions, no PrismaAdapter.

  • AUTH_SECRET must be at least 32 bytes: openssl rand -base64 32
  • All user fields (id, role, plan, subscriptionStatus, etc.) are stored in the JWT token at sign-in
  • The session callback reads from the token only — zero database queries on navigation
  • Sessions revalidate every 15 minutes to pick up role/plan changes
  • A lightweight updatedAt check runs every 60 seconds to detect revoked sessions

Role-Based Access Control

Use session helpers in server components and API routes — never in middleware:

import { getRequiredSession } from "@/lib/auth-utils"
import { getAdminSession } from "@/lib/auth-utils"
import { getSuperAdminSession } from "@/lib/auth-utils"

// Any authenticated user — redirects to /login if not signed in
const session = await getRequiredSession()

// ADMIN role only (SUPERADMIN gets redirected to /dashboard/superadmin)
const session = await getAdminSession()

// SUPERADMIN role only (ADMIN gets redirected to /dashboard/admin)
const session = await getSuperAdminSession()

All helpers are wrapped with React.cache() — multiple calls in the same request are deduplicated.

There is no middleware.ts. Route protection is enforced server-side via getRequiredSession() in server components and API routes.


Webhook Signature Verification

All three payment providers verify the HMAC signature before processing any webhook event:

LemonSqueezy — HMAC-SHA256 with LEMONSQUEEZY_WEBHOOK_SECRET:

const hmac = createHmac("sha256", process.env.LEMONSQUEEZY_WEBHOOK_SECRET!)
const digest = hmac.update(rawBody).digest("hex")
if (!timingSafeEqual(Buffer.from(signature), Buffer.from(digest))) {
  return new Response("Invalid signature", { status: 401 })
}

Polar — uses Webhooks.constructEvent() from @polar-sh/sdk/webhooks:

const webhooks = new Webhooks({ secret: process.env.POLAR_WEBHOOK_SECRET! })
const event = webhooks.constructEvent(payload)

Stripe — uses stripe.webhooks.constructEvent():

const event = stripe.webhooks.constructEvent(rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET!)

Never process a webhook event without first verifying the signature.


Input Validation with Zod

Every API route validates request bodies with Zod before touching the database:

// Standard pattern: Auth → Zod → logic → response
export async function POST(req: Request) {
  try {
    const session = await getRequiredSession()           // 1. Auth
    const body = await req.json()
    const data = createSchema.parse(body)                // 2. Validate
    const result = await db.myModel.create({ ... })      // 3. Logic
    return NextResponse.json({ success: true, data: result })
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { success: false, error: error.issues[0].message },
        { status: 400 }
      )
    }
    return NextResponse.json({ success: false, error: "Something went wrong" }, { status: 500 })
  }
}

SQL Injection Protection

All database queries use Prisma's parameterized query builder. Raw SQL is never used. Prisma's select requirement further limits data exposure.


XSS Protection

React escapes all rendered values by default. dangerouslySetInnerHTML is not used in the codebase. Email templates use @react-email/components which also handles escaping.


Rate Limiting

When the rate-limiting feature is enabled, API routes use Upstash Redis with three tiers:

TierLimitUse Case
strict10/minAuth endpoints (login, signup, password reset)
standard30/minGeneral CRUD API routes
lenient60/minRead-heavy endpoints (search, listings)

Gracefully degrades to allow-all when UPSTASH_REDIS_REST_URL is not set.


Environment Variable Safety

  • Server-only secrets (DATABASE_URL, AUTH_SECRET, STRIPE_SECRET_KEY, etc.) are never exposed to the client
  • Client-safe values use the NEXT_PUBLIC_ prefix only when they are safe to expose (provider keys, feature flags)
  • Never commit .env.local — it is in .gitignore

CORS

Next.js does not add permissive CORS headers by default. API routes are same-origin unless you explicitly add Access-Control-Allow-Origin headers — which is not done in this codebase.

Demo Mode — Explore freely. Some actions are restricted. demo@launchfst.dev / demo1234

Get LaunchFst →