Comment sécuriser une API REST : JWT, rate limiting, CORS et validation
Une API non sécurisée n'est pas une API — c'est une porte ouverte. Voici les couches de protection concrètes à mettre en place avant d'exposer quoi que ce soit en production.
J'ai audité des APIs où le token JWT était stocké dans l'URL, où CORS était *, et où les paramètres SQL étaient concaténés directement dans la requête. Ces vulnérabilités ne viennent pas d'un manque de compétence — elles viennent d'un manque de checklist.
Ce guide est cette checklist : les couches de protection concrètes, dans l'ordre où vous devriez les mettre en place.
1. HTTPS : la fondation
Aucune autre sécurité ne sert à rien sans HTTPS. HTTP envoie les tokens, mots de passe et données en clair sur le réseau.
# Let's Encrypt avec Certbot — gratuit, automatique
sudo certbot --nginx -d api.monsite.fr
# Forcer HTTPS dans Nginx
server {
listen 80;
return 301 https://$host$request_uri;
}En production : HTTPS uniquement, jamais d'API en HTTP.
2. Authentification JWT
JWT permet de vérifier l'identité sans état côté serveur. Les règles critiques :
import jwt from 'jsonwebtoken'
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET // 256 bits aléatoires
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET // Secret distinct
// Générer un access token court (15-60 minutes)
function generateAccessToken(user) {
return jwt.sign(
{ sub: user.id, role: user.role }, // Pas de données sensibles dans le payload
ACCESS_SECRET,
{ expiresIn: '15m', algorithm: 'HS256' }
)
}
// Vérifier avec algorithme forcé
function verifyToken(token) {
return jwt.verify(token, ACCESS_SECRET, { algorithms: ['HS256'] })
// Forcer l'algorithme évite l'attaque "alg: none"
}
// Middleware d'authentification
export function authenticate(req, res, next) {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Token manquant' })
}
try {
const token = authHeader.slice(7)
req.user = verifyToken(token)
next()
} catch (err) {
res.status(401).json({ error: 'Token invalide ou expiré' })
}
}app.get('/api/profile', authenticate, (req, res) => {
res.json({ userId: req.user.sub })
})3. Rate limiting
Sans rate limiting, n'importe qui peut bombarder votre API avec des milliers de requêtes par seconde — brute force de mots de passe, DDoS applicatif, scraping.
npm install express-rate-limitimport rateLimit from 'express-rate-limit'
// Limite globale — 100 requêtes par 15 minutes par IP
const globalLimit = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true, // Expose RateLimit-* headers
legacyHeaders: false,
message: { error: 'Trop de requêtes. Réessayez dans quelques minutes.' },
})
// Limite stricte pour le login — 5 tentatives par 15 minutes
const loginLimit = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
skipSuccessfulRequests: true, // Ne compte pas les succès
})
app.use('/api/', globalLimit)
app.post('/auth/login', loginLimit, loginHandler)Pour du rate limiting distribué (plusieurs instances d'API), Redis centralise les compteurs — chaque instance partage la même fenêtre de temps.
import { createClient } from 'redis'
const redis = createClient()
async function rateLimitWithRedis(ip, limit = 100, windowSec = 900) {
const key = `ratelimit:${ip}`
const current = await redis.incr(key)
if (current === 1) await redis.expire(key, windowSec)
return current <= limit
}4. CORS : contrôler les origines autorisées
CORS (Cross-Origin Resource Sharing) détermine quels domaines peuvent appeler votre API depuis un navigateur.
import cors from 'cors'
const allowedOrigins = [
'https://monsite.fr',
'https://www.monsite.fr',
process.env.NODE_ENV === 'development' && 'http://localhost:3000',
].filter(Boolean)
const corsOptions = {
origin: (origin, callback) => {
// Autoriser les requêtes sans origin (Postman, curl, serveur-à-serveur)
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true)
} else {
callback(new Error(`Origine non autorisée: ${origin}`))
}
},
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // Nécessaire si vous utilisez des cookies
maxAge: 86400, // Cache CORS preflight 24h
}
app.use(cors(corsOptions))Jamais origin: '*' en production — surtout avec credentials: true, qui est d'ailleurs impossible avec *. Un wildcard expose votre API à n'importe quel site.
5. Validation des entrées
Toute donnée qui vient de l'extérieur est suspecte. Validez la forme et le type avant tout traitement.
npm install zodimport { z } from 'zod'
// Définir le schema
const CreateUserSchema = z.object({
name: z.string().min(2).max(100).trim(),
email: z.string().email(),
password: z.string()
.min(8)
.regex(/[A-Z]/, 'Doit contenir au moins une majuscule')
.regex(/\d/, 'Doit contenir au moins un chiffre'),
role: z.enum(['user', 'admin']).default('user'),
age: z.number().int().positive().optional(),
})
// Middleware de validation
function validate(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body)
if (!result.success) {
return res.status(400).json({
error: 'Données invalides',
details: result.error.flatten().fieldErrors,
})
}
req.body = result.data // Remplacer req.body par les données parsées et typées
next()
}
}
// Usage
app.post('/users', validate(CreateUserSchema), async (req, res) => {
const { name, email, password, role } = req.body // Typé et validé
// ...
})Validez aussi les paramètres URL et les query strings :
const ParamsSchema = z.object({
id: z.string().uuid(),
})
const QuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
})
app.get('/users/:id', (req, res) => {
const params = ParamsSchema.safeParse(req.params)
const query = QuerySchema.safeParse(req.query)
if (!params.success || !query.success) {
return res.status(400).json({ error: 'Paramètres invalides' })
}
// ...
})6. Protection contre l'injection SQL
Avec un ORM ou un query builder, utilisez toujours des paramètres — jamais de concaténation.
// ❌ Injection SQL — DANGER
const { name } = req.query
await db.query(`SELECT * FROM users WHERE name = '${name}'`)
// name = "'; DROP TABLE users; --" → catastrophe
// ✅ Paramètres préparés (pg)
await db.query('SELECT * FROM users WHERE name = $1', [name])
// ✅ Prisma, Drizzle, Knex — tous paramétrisés par défaut
const user = await prisma.user.findFirst({ where: { name } })Pour le NoSQL (MongoDB), la même logique s'applique — ne jamais construire des filtres depuis des entrées brutes :
// ❌ NoSQL injection
const filter = JSON.parse(req.query.filter) // { $where: "sleep(5000)" }
await collection.findOne(filter)
// ✅ Construire le filtre explicitement
await collection.findOne({ name: req.query.name })7. Headers de sécurité
Helmet.js ajoute des headers HTTP qui protègent contre les attaques navigateur classiques.
npm install helmetimport helmet from 'helmet'
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
}))Headers ajoutés par Helmet :
X-Frame-Options: SAMEORIGIN— protection clickjackingX-Content-Type-Options: nosniff— empêche le MIME sniffingStrict-Transport-Security— force HTTPSX-XSS-Protection: 1; mode=blockContent-Security-Policy— contrôle les ressources chargées
8. Logging sécurisé
import pino from 'pino'
const logger = pino({
redact: ['password', 'token', 'authorization', 'creditCard'],
// Ces champs sont remplacés par [Redacted] dans les logs
})
// ❌ Logger des données sensibles
logger.info({ password: req.body.password, email: req.body.email }, 'Login attempt')
// ✅ Logger seulement ce qui est nécessaire
logger.info({ email: req.body.email, ip: req.ip }, 'Login attempt')
logger.info({ userId: req.user.sub, route: req.path }, 'Request')Jamais dans les logs : mots de passe, tokens, numéros de carte, données personnelles au-delà de ce qui est nécessaire pour le debug.
9. Gestion des erreurs — ne pas exposer l'internals
// ❌ Exposer les détails d'erreur en production
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message, stack: err.stack })
// "SELECT * FROM users WHERE..." révèle votre schéma
})
// ✅ Erreur générique en prod, détails en dev
app.use((err, req, res, next) => {
logger.error({ err, userId: req.user?.sub, path: req.path }, 'Request error')
const isDev = process.env.NODE_ENV === 'development'
res.status(err.status ?? 500).json({
error: isDev ? err.message : 'Erreur interne',
...(isDev && { stack: err.stack }),
})
})Checklist avant de passer en production
| Couche | Vérifié |
|---|---|
| HTTPS activé, HTTP redirigé | ✓ |
| JWT : secret fort, expiration courte, algorithme forcé | ✓ |
| Rate limiting sur login et routes sensibles | ✓ |
| CORS : liste blanche d'origines, pas de wildcard | ✓ |
| Validation des entrées avec schema (Zod/Joi) | ✓ |
| Requêtes SQL/NoSQL paramétrées | ✓ |
| Helmet.js configuré | ✓ |
| Logs sans données sensibles | ✓ |
| Erreurs génériques en production | ✓ |
| Variables d'environnement hors du code | ✓ |
La sécurité n'est pas un état — c'est une pratique. Ces couches éliminent les vulnérabilités les plus courantes, mais le vecteur d'attaque évolue. Les bases de la cybersécurité pour les développeurs couvre les menaces plus larges — XSS, CSRF, injections — et comment raisonner sur les surfaces d'attaque au-delà de l'API elle-même.
Pour les schémas de validation les plus précis — formats d'email, slugs, numéros de téléphone — les expressions régulières complètent Zod avec des patterns réutilisables que vous retrouverez dans toute votre stack.