Dev FullStack

Retour au blog
Développement

Server Actions Next.js 15 : Le guide sécurité que personne ne vous donne (+ 7 patterns production)

Les Server Actions révolutionnent Next.js 15, mais 80% des développeurs oublient la sécurité. Découvrez les 7 patterns production essentiels : validation Zod, rate limiting Redis, gestion d'erreurs avec useActionState et authentification bulletproof.

Hermann MOUSSAVOU
27 octobre 2025
18 min
Server Actions Next.js 15 : Le guide sécurité que personne ne vous donne (+ 7 patterns production)

Les Server Actions de Next.js 15 sont une révolution. Mais il y a un problème majeur : la plupart des tutoriels vous montrent comment les utiliser, pas comment les sécuriser.

Après avoir déployé 3 applications production avec Server Actions (dont mon propre portfolio avec formulaire de contact), j'ai appris à mes dépens que chaque Server Action est un endpoint HTTP public. Oui, public. Accessible par n'importe qui.

Voici le guide complet que j'aurais aimé avoir il y a 6 mois.

Pourquoi la sécurité des Server Actions est critique

Le mythe de la "sécurité par obscurcissement"

Next.js 15 génère des IDs non-déterministes pour vos Server Actions. Beaucoup pensent que c'est suffisant pour la sécurité.

C'est faux.

// Votre Server Action
'use server'
export async function deleteUser(userId: string) {
  await db.user.delete({ where: { id: userId } })
}

Ce que Next.js fait : Génère un ID aléatoire comme a1b2c3d4e5

Le problème : Cet ID est visible dans :

  • Le code source client (bundle JS)
  • Les DevTools Network
  • Les requêtes HTTP interceptées

⚠️ Règle d'or : Traitez chaque Server Action comme un endpoint API public. Point final.

Pattern #1 : Validation avec Zod (obligatoire)

Pourquoi Zod et pas juste TypeScript ?

TypeScript disparaît à la compilation. Zod valide au runtime.

// ❌ DANGEREUX - TypeScript seul
'use server'
export async function createPost(title: string, content: string) {
  // Aucune validation runtime !
  await db.post.create({ data: { title, content } })
}
// ✅ SÉCURISÉ - Avec Zod
'use server'
import { z } from 'zod'

const CreatePostSchema = z.object({
  title: z.string().min(3).max(100),
  content: z.string().min(10).max(5000),
  category: z.enum(['tech', 'design', 'tutorial']),
})

export async function createPost(formData: FormData) {
  // 1. Parser les données brutes
  const rawData = {
    title: formData.get('title'),
    content: formData.get('content'),
    category: formData.get('category'),
  }

  // 2. Valider avec safeParse (ne throw pas)
  const validation = CreatePostSchema.safeParse(rawData)

  if (!validation.success) {
    return {
      success: false,
      errors: validation.error.flatten().fieldErrors,
    }
  }

  // 3. Données validées et typées !
  const { title, content, category } = validation.data

  await db.post.create({ data: { title, content, category } })

  return { success: true }
}

Pattern avancé : next-safe-action

Pour les projets complexes, utilisez next-safe-action :

npm install next-safe-action zod
// lib/safe-actions.ts
import { createSafeActionClient } from "next-safe-action"

export const actionClient = createSafeActionClient()
// actions/posts.ts
'use server'
import { actionClient } from "@/lib/safe-actions"
import { z } from "zod"

const createPostSchema = z.object({
  title: z.string().min(3).max(100),
  content: z.string().min(10).max(5000),
})

export const createPostAction = actionClient
  .schema(createPostSchema)
  .action(async ({ parsedInput: { title, content } }) => {
    // parsedInput est déjà validé et typé !
    await db.post.create({ data: { title, content } })
    return { success: true }
  })

Avantages :

  • ✅ Validation automatique
  • ✅ Types TypeScript inférés
  • ✅ Gestion d'erreurs intégrée
  • ✅ Middleware support (auth, logging...)

📚 Ressource : next-safe-action.dev

Pattern #2 : Gestion d'erreurs avec useActionState

L'approche Next.js 15 officielle

// actions/contact.ts
'use server'
import { z } from 'zod'

const ContactSchema = z.object({
  email: z.string().email('Email invalide'),
  message: z.string().min(10, 'Message trop court'),
})

type ActionState = {
  success: boolean
  message?: string
  errors?: Record<string, string[]>
}

export async function sendContactAction(
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  // Validation
  const validation = ContactSchema.safeParse({
    email: formData.get('email'),
    message: formData.get('message'),
  })

  if (!validation.success) {
    return {
      success: false,
      errors: validation.error.flatten().fieldErrors,
    }
  }

  try {
    // Envoi email (nodemailer, resend, etc.)
    await sendEmail(validation.data)

    return {
      success: true,
      message: 'Message envoyé avec succès !',
    }
  } catch (error) {
    console.error('Contact form error:', error)

    return {
      success: false,
      message: 'Erreur lors de l'envoi. Réessayez plus tard.',
    }
  }
}

Côté client avec useActionState

'use client'
import { useActionState } from 'react'
import { sendContactAction } from '@/actions/contact'

export function ContactForm() {
  const [state, formAction, isPending] = useActionState(
    sendContactAction,
    { success: false }
  )

  return (
    <form action={formAction}>
      <input
        type="email"
        name="email"
        required
      />
      {state.errors?.email && (
        <p className="text-red-500">{state.errors.email[0]}</p>
      )}

      <textarea name="message" required />
      {state.errors?.message && (
        <p className="text-red-500">{state.errors.message[0]}</p>
      )}

      <button type="submit" disabled={isPending}>
        {isPending ? 'Envoi...' : 'Envoyer'}
      </button>

      {state.success && (
        <p className="text-green-500">{state.message}</p>
      )}
    </form>
  )
}

Avantages de useActionState :

  • ✅ État pending automatique
  • ✅ Pas de useState/useEffect manuel
  • ✅ Progressive enhancement (fonctionne sans JS)
  • ✅ Retours d'erreurs structurés

⚠️ Important : N'utilisez jamais throw pour les erreurs attendues. Retournez toujours un objet.

Pattern #3 : Rate Limiting avec Redis

Pourquoi le rate limiting est essentiel

Sans rate limiting, un attaquant peut :

  • Spammer votre formulaire de contact (comme le mien !)
  • Surcharger votre base de données
  • Générer des coûts d'API (emails, SMS, etc.)

Installation

npm install @upstash/ratelimit @upstash/redis

Configuration Upstash Redis

  1. Créez un compte sur Upstash
  2. Créez une base Redis
  3. Copiez les variables d'environnement
# .env.local
UPSTASH_REDIS_REST_URL="https://..."
UPSTASH_REDIS_REST_TOKEN="..."

Implémentation

// lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit"
import { Redis } from "@upstash/redis"

// Créer l'instance Redis
const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})

// Sliding window : 5 requêtes par 60 secondes
export const ratelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(5, "60 s"),
  analytics: true, // Stats dans Upstash dashboard
})

Utilisation dans Server Action

// actions/contact.ts
'use server'
import { ratelimit } from "@/lib/rate-limit"
import { headers } from "next/headers"

export async function sendContactAction(
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  // 1. Récupérer l'IP
  const headersList = await headers()
  const ip = headersList.get("x-forwarded-for") || "unknown"

  // 2. Vérifier le rate limit
  const { success: rateLimitOk } = await ratelimit.limit(ip)

  if (!rateLimitOk) {
    return {
      success: false,
      message: "Trop de tentatives. Réessayez dans 1 minute.",
    }
  }

  // 3. Continuer normalement
  // ... validation, envoi email, etc.
}

Stratégies de rate limiting

StratégieUsageExemple
Fixed WindowSimple, basique10 req/minute
Sliding WindowPrécis, recommandé5 req/60s glissantes
Token BucketBurst autorisé20 tokens, +1/sec

📚 Ressource : Upstash Rate Limiting Guide

Pattern #4 : Authentification (souvent oubliée)

Le piège classique

// ❌ VULNÉRABLE
'use server'
export async function deletePost(postId: string) {
  // Aucune vérification d'authentification !
  await db.post.delete({ where: { id: postId } })
}

N'importe qui peut appeler cette fonction depuis la console :

// Dans la console navigateur
fetch('/api/...')
  .then(res => res.json())
  .then(data => console.log('Post supprimé !'))

Solution : Toujours vérifier la session

// ✅ SÉCURISÉ
'use server'
import { auth } from "@/lib/auth" // NextAuth, Clerk, etc.

export async function deletePost(postId: string) {
  // 1. Vérifier l'authentification
  const session = await auth()

  if (!session?.user) {
    return { success: false, error: 'Non authentifié' }
  }

  // 2. Vérifier l'autorisation (ownership)
  const post = await db.post.findUnique({
    where: { id: postId },
    select: { authorId: true },
  })

  if (post?.authorId !== session.user.id) {
    return { success: false, error: 'Non autorisé' }
  }

  // 3. Effectuer l'action
  await db.post.delete({ where: { id: postId } })

  return { success: true }
}

Authentification ≠ Autorisation :

  • Authentification : "Qui êtes-vous ?"
  • Autorisation : "Pouvez-vous faire ça ?"

⚠️ Toujours vérifier les deux dans les Server Actions sensibles.

Pattern #5 : Revalidation intelligente

Le problème du cache

Next.js met en cache agressivement. Après une mutation, vos données peuvent être obsolètes.

// actions/posts.ts
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'

export async function createPost(data: PostData) {
  const newPost = await db.post.create({ data })

  // Méthode 1 : Revalider un path spécifique
  revalidatePath('/blog')

  // Méthode 2 : Revalider par tag (recommandé)
  revalidateTag('posts')

  return { success: true, post: newPost }
}

Stratégie de tags (avancée)

// lib/cache.ts
export const CacheTags = {
  posts: 'posts',
  postDetail: (id: string) => \`post-\${id}\`,
  user: (id: string) => \`user-\${id}\`,
}

// Dans votre fetch
export async function getPost(id: string) {
  return fetch(\`/api/posts/\${id}\`, {
    next: {
      tags: [CacheTags.posts, CacheTags.postDetail(id)],
    },
  })
}

// Dans votre Server Action
export async function updatePost(id: string, data: PostData) {
  await db.post.update({ where: { id }, data })

  revalidateTag(CacheTags.posts)
  revalidateTag(CacheTags.postDetail(id))
}

Pattern #6 : Logging et monitoring

Pourquoi c'est crucial en production

Quand un Server Action échoue, vous devez savoir :

  • Quelle action a échoué
  • Pour quel utilisateur
  • Avec quelles données
  • À quelle heure
// lib/logger.ts
export function logServerAction(
  action: string,
  userId: string | null,
  data: unknown,
  success: boolean,
  error?: unknown
) {
  console.log({
    timestamp: new Date().toISOString(),
    action,
    userId,
    data: JSON.stringify(data),
    success,
    error: error ? String(error) : undefined,
  })

  // En production, envoyez à Sentry, LogRocket, etc.
}
// actions/posts.ts
export async function createPost(data: PostData) {
  try {
    const post = await db.post.create({ data })

    logServerAction('createPost', session.user.id, data, true)

    return { success: true, post }
  } catch (error) {
    logServerAction('createPost', session.user.id, data, false, error)

    return {
      success: false,
      error: 'Erreur lors de la création',
    }
  }
}

Pattern #7 : Tests des Server Actions

Pourquoi tester ?

Les Server Actions sont du code serveur. Ils doivent être testés comme des API endpoints.

// __tests__/actions/posts.test.ts
import { createPost } from '@/actions/posts'
import { prismaMock } from '@/lib/prisma-mock'

describe('createPost Server Action', () => {
  it('devrait créer un post avec des données valides', async () => {
    const mockPost = { id: '1', title: 'Test', content: 'Content' }

    prismaMock.post.create.mockResolvedValue(mockPost)

    const result = await createPost({
      title: 'Test',
      content: 'Content',
    })

    expect(result.success).toBe(true)
    expect(result.post).toEqual(mockPost)
  })

  it('devrait rejeter des données invalides', async () => {
    const result = await createPost({
      title: '', // Titre vide
      content: 'Content',
    })

    expect(result.success).toBe(false)
    expect(result.errors?.title).toBeDefined()
  })
})

Mon retour d'expérience production

Ce que j'ai appris sur mon portfolio

Mon formulaire de contact utilise :

  • ✅ Validation Zod côté serveur
  • ✅ Rate limiting Redis (5 messages/heure/IP)
  • ✅ Nodemailer pour l'envoi
  • ✅ useActionState pour l'UX

Résultat : 0 spam en 3 mois, 100% de messages légitimes.

Erreurs que j'ai faites (pour que vous les évitiez)

  1. Oublier le rate limiting → Spam massif le premier jour
  2. Throw des erreurs au lieu de les retourner → Mauvaise UX
  3. Ne pas logger → Impossible de debug en production
  4. Validation uniquement côté client → Données corrompues en DB

Checklist sécurité Server Actions

Avant de déployer, vérifiez :

  • Validation Zod sur toutes les entrées
  • Rate limiting sur les actions sensibles
  • Authentification vérifiée côté serveur
  • Autorisation (ownership) vérifiée
  • Erreurs retournées, pas throw
  • useActionState pour l'UX
  • Revalidation après mutations
  • Logging des actions critiques
  • Tests unitaires écrits
  • Variables d'environnement sécurisées

Ressources essentielles

Documentation officielle

Outils recommandés

Articles complémentaires

Conclusion : La sécurité n'est pas optionnelle

Les Server Actions sont puissants, mais dangereux si mal utilisés.

Les 3 règles d'or :

  1. 🔐 Validez tout côté serveur (Zod)
  2. ⏱️ Limitez les appels (Rate limiting)
  3. 🛡️ Vérifiez auth + autorisation

En suivant ces 7 patterns, vos Server Actions seront production-ready, sécurisées et performantes.

Mon conseil final : Commencez simple. Ajoutez la sécurité dès le début. C'est beaucoup plus facile que de la rajouter après coup.

Les Server Actions ne sont pas qu'une nouvelle syntaxe - c'est un nouveau paradigme. Apprenez à les sécuriser correctement, et vous aurez un avantage énorme sur 80% des développeurs Next.js.

Prêt à sécuriser vos Server Actions ? Commencez par Zod et rate limiting. Le reste suivra naturellement. 🚀