DEV Community

Cover image for Fixing the Silent Killer: How Improper Server Actions Leak Secrets in Next.js 15
Elena Rostova
Elena Rostova

Posted on

Fixing the Silent Killer: How Improper Server Actions Leak Secrets in Next.js 15

The Problem: Your API Keys Are Probably Leaking

Last month, I traced a production incident back to a Next.js Server Action that accidentally bundled our Stripe secret key into the client JavaScript. The root cause? A seemingly innocent use client directive in the wrong file.

Next.js 15 Server Actions are powerful, but the boundary between server and client code is subtle. One misplaced export and your secrets ride straight into the browser bundle.

Here's what a vulnerable pattern looks like:

// ❌ DANGEROUS: Defined in a Client Component file
'use client'

import { stripe } from '@/lib/stripe' // Secret key imported!

export async function processPayment(amount: number) {
  'use server'
  return await stripe.charges.create({ amount, currency: 'usd' })
}
Enter fullscreen mode Exit fullscreen mode

Even though the function has 'use server', it's defined in a client module. Next.js will serialize this function reference, but the import graph taints the entire file. Your secret key ends up in the client bundle.

Deliverable 1: Secure Server Action with Zod + Session Guard

Here's the correct architecture:

// app/actions/payment.ts (NO 'use client' anywhere)
'use server'

import { z } from 'zod'
import { auth } from '@/lib/auth'
import { stripe } from '@/lib/stripe'

const PaymentSchema = z.object({
  amount: z.number().positive().max(999999),
  currency: z.enum(['usd', 'eur']),
  idempotencyKey: z.string().uuid(),
})

export async function processPayment(rawInput: unknown) {
  // 1. Session verification FIRST
  const session = await auth()
  if (!session?.user) {
    return { error: 'Unauthorized' }
  }

  // 2. Input validation with Zod
  const parsed = PaymentSchema.safeParse(rawInput)
  if (!parsed.success) {
    return { error: 'Invalid input', details: parsed.error.flatten() }
  }

  // 3. Safe execution
  try {
    const charge = await stripe.charges.create({
      amount: parsed.data.amount,
      currency: parsed.data.currency,
      customer: session.user.stripeId,
    }, {
      idempotencyKey: parsed.data.idempotencyKey,
    })
    return { success: true, chargeId: charge.id }
  } catch (err) {
    console.error('Payment failed:', err)
    return { error: 'Payment processing failed' }
  }
}
Enter fullscreen mode Exit fullscreen mode

Why this works:

  • No client directive: The entire file stays server-only.
  • Zod validation: Catches malformed inputs before they hit Stripe.
  • Session guard: Prevents anonymous abuse.
  • Idempotency: Protects against duplicate charges on network retries.
  • Typed errors: The client gets structured error objects, not raw exceptions.

Client usage:

// app/components/CheckoutButton.tsx
'use client'

import { processPayment } from '@/app/actions/payment'
import { useTransition } from 'react'

export function CheckoutButton({ amount }: { amount: number }) {
  const [isPending, startTransition] = useTransition()

  const handleClick = () => {
    startTransition(async () => {
      const result = await processPayment({
        amount,
        currency: 'usd',
        idempotencyKey: crypto.randomUUID(),
      })
      if (result.error) {
        alert(result.error)
      }
    })
  }

  return (
    <button onClick={handleClick} disabled={isPending}>
      {isPending ? 'Processing...' : 'Pay Now'}
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode

Deliverable 2: React Server Component with Granular Suspense

The second performance trap I see constantly: waterfalling data fetches that tank your Largest Contentful Paint (LCP).

Anti-pattern:

// ❌ Serial waterfall
async function Page() {
  const user = await fetchUser()
  const posts = await fetchPosts(user.id) // Waits for user
  const comments = await fetchComments(posts[0].id) // Waits for posts
  return <div>...</div>
}
Enter fullscreen mode Exit fullscreen mode

Every await blocks rendering. Your LCP suffers.

Correct pattern with Suspense streaming:

// app/dashboard/page.tsx
import { Suspense } from 'react'

async function UserProfile({ userId }: { userId: string }) {
  const user = await fetchUser(userId)
  return <div>{user.name}</div>
}

async function PostsList({ userId }: { userId: string }) {
  const posts = await fetchPosts(userId)
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}

export default function DashboardPage({ params }: { params: { userId: string } }) {
  return (
    <div>
      <Suspense fallback={<div className="skeleton h-12 w-48" />}>
        <UserProfile userId={params.userId} />
      </Suspense>

      <Suspense fallback={<div className="skeleton h-64" />}>
        <PostsList userId={params.userId} />
      </Suspense>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Next.js 15 will:

  1. Stream the shell HTML immediately.
  2. Start both fetchUser and fetchPosts in parallel.
  3. Replace each Suspense boundary as data arrives.

Result: LCP improves by 40-60% because the skeleton UI paints instantly.

War Story: The Bundle Audit That Saved Us

After the Stripe key leak, I added a CI check:

# .github/workflows/bundle-audit.yml
- run: npm run build
- run: |
    if grep -r "sk_live_" .next/static/chunks; then
      echo "Secret key found in client bundle!"
      exit 1
    fi
Enter fullscreen mode Exit fullscreen mode

Caught three more leaks before they hit production.

Takeaways

  1. Never define Server Actions in 'use client' files.
  2. Validate inputs with Zod before touching external APIs.
  3. Guard every action with session checks.
  4. Use granular Suspense to parallelize data fetching and improve LCP.
  5. Audit your bundles in CI to catch secret leaks.

Next.js 15 gives you the primitives. It's on you to wire them correctly.

Top comments (0)