💻 Développement · · 4 min

Sécuriser une API REST : Guide pratique avec Node.js

Les bonnes pratiques pour sécuriser une API REST — authentification, validation, rate limiting et protection contre les attaques courantes.

Pourquoi la sécurité d’une API est critique

Une API REST est souvent la porte d’entrée de votre application. Si elle est mal sécurisée, c’est l’ensemble de votre système qui est compromis. Après avoir participé à plusieurs CTF et audité des applications web, j’ai compilé les bonnes pratiques essentielles pour sécuriser une API Node.js.

1. Authentification robuste avec JWT

L’erreur la plus courante : stocker le JWT dans le localStorage. Préférez un httpOnly cookie pour éviter les attaques XSS.

import jwt from 'jsonwebtoken';
import { Request, Response, NextFunction } from 'express';

const JWT_SECRET = process.env.JWT_SECRET!;
const JWT_EXPIRY = '15m'; // Courte durée de vie

export function generateTokens(userId: string) {
  const accessToken = jwt.sign({ sub: userId }, JWT_SECRET, {
    expiresIn: JWT_EXPIRY,
  });

  const refreshToken = jwt.sign({ sub: userId, type: 'refresh' }, JWT_SECRET, {
    expiresIn: '7d',
  });

  return { accessToken, refreshToken };
}

export function authMiddleware(req: Request, res: Response, next: NextFunction) {
  const token = req.cookies?.accessToken;

  if (!token) {
    return res.status(401).json({ error: 'Non authentifié' });
  }

  try {
    const payload = jwt.verify(token, JWT_SECRET);
    req.userId = (payload as any).sub;
    next();
  } catch {
    return res.status(401).json({ error: 'Token invalide ou expiré' });
  }
}

Points clés :

  • Token d’accès avec courte durée de vie (15 min)
  • Refresh token séparé pour renouveler la session
  • Stockage en httpOnly cookie (pas en localStorage)
  • Rotation des refresh tokens à chaque utilisation

2. Validation des entrées

Ne faites jamais confiance aux données entrantes. Utilisez un schéma de validation strict avec Zod :

import { z } from 'zod';

const createUserSchema = z.object({
  email: z.string().email().max(255),
  password: z
    .string()
    .min(12, 'Minimum 12 caractères')
    .regex(/[A-Z]/, 'Au moins une majuscule')
    .regex(/[0-9]/, 'Au moins un chiffre')
    .regex(/[^A-Za-z0-9]/, 'Au moins un caractère spécial'),
  name: z.string().min(2).max(100).trim(),
});

// Dans le handler
app.post('/api/users', async (req, res) => {
  const result = createUserSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({
      error: 'Données invalides',
      details: result.error.flatten(),
    });
  }

  // result.data est maintenant typé et validé
  const user = await createUser(result.data);
  res.status(201).json(user);
});

3. Rate Limiting

Protégez-vous contre les attaques par brute force et déni de service :

import rateLimit from 'express-rate-limit';

// Limite globale
const globalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Trop de requêtes, réessayez plus tard' },
});

// Limite stricte pour l'authentification
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5, // 5 tentatives par 15 min
  skipSuccessfulRequests: true,
});

app.use(globalLimiter);
app.use('/api/auth', authLimiter);

4. Headers de sécurité

Utilisez Helmet pour configurer les headers HTTP de sécurité :

import helmet from 'helmet';

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:", "https:"],
    },
  },
  crossOriginEmbedderPolicy: true,
  crossOriginOpenerPolicy: true,
  crossOriginResourcePolicy: { policy: "same-site" },
  hsts: { maxAge: 31536000, includeSubDomains: true },
}));

5. Protection contre les injections SQL

Même avec un ORM, restez vigilant. Utilisez toujours des requêtes paramétrées :

// ❌ JAMAIS ça
const user = await db.query(`SELECT * FROM users WHERE id = ${req.params.id}`);

// ✅ Requête paramétrée
const user = await db.query('SELECT * FROM users WHERE id = $1', [req.params.id]);

// ✅ Avec Prisma (ORM)
const user = await prisma.user.findUnique({
  where: { id: parseInt(req.params.id) },
});

6. Logging et monitoring

Loguez les événements de sécurité pour détecter les comportements suspects :

import winston from 'winston';

const securityLogger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  defaultMeta: { service: 'security' },
  transports: [
    new winston.transports.File({ filename: 'security.log' }),
  ],
});

// Logger les tentatives échouées
app.post('/api/auth/login', async (req, res) => {
  try {
    const user = await authenticate(req.body);
    securityLogger.info('Login réussi', {
      userId: user.id,
      ip: req.ip,
    });
    // ...
  } catch {
    securityLogger.warn('Tentative de login échouée', {
      email: req.body.email,
      ip: req.ip,
      userAgent: req.headers['user-agent'],
    });
    // ...
  }
});

Checklist récapitulative

MesurePrioritéStatus
JWT en httpOnly cookie🔴 CritiqueÀ implémenter
Validation des entrées (Zod)🔴 CritiqueÀ implémenter
Rate limiting🟠 HauteÀ implémenter
Headers de sécurité (Helmet)🟠 HauteÀ implémenter
Requêtes paramétrées🔴 CritiqueÀ implémenter
CORS configuré🟠 HauteÀ implémenter
Logging sécurité🟡 MoyenneÀ implémenter
HTTPS obligatoire🔴 CritiqueÀ implémenter

Conclusion

La sécurité n’est pas une fonctionnalité qu’on ajoute à la fin — c’est une philosophie de développement. Chaque ligne de code qui interagit avec l’extérieur est une surface d’attaque potentielle.

Mon conseil : commencez par les mesures critiques (validation, authentification, injections), puis ajoutez les couches supplémentaires progressivement.


Cet article est basé sur mon expérience en CTF et en développement d’applications sécurisées. Pour voir mes projets, visitez mon portfolio.

Salem GNANDI

Développeur · Cybersécurité