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
| Mesure | Priorité | 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é