Qu’est-ce que l’authentification par session
L’authentification par session est la méthode la plus ancienne, et encore aujourd’hui la plus courante, pour maintenir un utilisateur connecté sur le web. Le concept est simple : le serveur se souvient de vous, et le navigateur détient un reçu.
Lorsque vous vous connectez, le serveur vérifie vos identifiants et crée une session — un enregistrement qu’il stocke lui-même. Cet enregistrement peut contenir votre id utilisateur, l’heure de création, la date d’expiration et tout état spécifique, comme un panier d’achat. Le serveur envoie ensuite à votre navigateur un session id : une longue chaîne de caractères aléatoires qui identifie cet enregistrement unique. Le navigateur stocke cet id dans un cookie et le renvoie à chaque requête. Un middleware sur le serveur lit l’id, charge l’enregistrement et sait alors qui vous êtes.
La caractéristique principale est que le navigateur détient un identifiant opaque, et non votre identité. C’est un ticket de vestiaire, pas le manteau lui-même. Si l’id fuite, un attaquant peut usurper votre identité jusqu’à ce que vous ou le serveur invalidiez l’enregistrement — mais l’id en lui-même ne révèle rien, ne peut être décodé et ne peut être modifié pour obtenir des permissions supplémentaires. Tout ce qui est significatif réside derrière la recherche effectuée par le serveur.
C’est l’opposé d’un jeton autonome tel qu’un JWT, où l’identifiant transporte des claims signés et où le serveur leur fait confiance sans avoir à interroger une base de données. L’authentification par session privilégie la recherche en base de données en échange d’un meilleur contrôle. Ce compromis est le sujet de ce guide.
Le cycle de vie d’une session
Chaque session suit le même schéma : créée lors de la connexion, transportée par un cookie, chargée à chaque requête et détruite lors de la déconnexion.
Création. Un POST /login réussi est le seul endroit où une session doit être générée. Générez un identifiant aléatoire cryptographiquement sécurisé — au moins 128 bits provenant d’un CSPRNG, jamais un compteur ou une valeur prévisible — et stockez un enregistrement indexé par cet identifiant. Définissez une date d’expiration. Si l’application propose l’option “se souvenir de moi”, choisissez délibérément une durée de vie plus longue plutôt que de la laisser au hasard.
Transport. Envoyez l’identifiant via un en-tête Set-Cookie. Le flag HttpOnly empêche l’accès via JavaScript, Secure restreint l’envoi au HTTPS, et SameSite limite les conditions dans lesquelles il est joint aux requêtes cross-site. Sans ces flags, l’identifiant de session est exposé aux failles XSS et au sniffing réseau.
Recherche. À chaque requête suivante, un middleware lit le cookie, recherche l’identifiant dans le store, puis associe l’utilisateur à la requête ou rejette celle-ci. Un identifiant manquant ou expiré signifie que la requête est anonyme et non qu’il s’agit d’une erreur, permettant ainsi aux pages publiques de continuer à fonctionner.
Destruction. La déconnexion supprime l’enregistrement du store et efface le cookie. C’est la suppression de l’enregistrement qui met réellement fin à la session ; l’effacement du cookie est une courtoisie qui évite au navigateur d’envoyer un identifiant obsolète. Une tâche d’expiration côté serveur doit également nettoyer les sessions abandonnées pour éviter que le store ne grossisse indéfiniment.
Ce cycle de vie est volontairement banal, et c’est précisément là son avantage. Il y a un seul endroit qui crée les sessions, un seul qui les charge et un seul qui les détruit — ce qui rend l’audit et les tests très simples.
Les mots de passe sont la première barrière
Une session ne peut être plus fiable que la connexion qui l’a créée ; le stockage des mots de passe mérite donc une attention particulière avant même l’apparition du cookie.
Ne stockez jamais un mot de passe en clair. Stockez un hash salé et lent, produit par un algorithme conçu à cet effet comme Argon2id, bcrypt ou scrypt. Ces algorithmes sont délibérément coûteux en ressources, ce qui transforme une fuite de base de données — qui serait autrement un dump instantané d’identifiants — en un travail de cassage long et onéreux. Un hash à usage général comme SHA-256 est le mauvais outil : il est rapide, ce qui est précisément ce qu’un attaquant recherche.
import argon2 from "argon2";
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, { type: argon2.argon2id });
}
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
try {
return await argon2.verify(hash, password);
} catch {
return false;
}
}
La vérification doit être effectuée en temps constant ou, mieux encore, déléguée à la fonction de comparaison propre à l’algorithme afin que le temps d’exécution ne fuite aucune information. Plus important encore, renvoyez la même erreur pour un e-mail inconnu et pour un mot de passe erroné. Indiquer “cet utilisateur n’existe pas” offre à un attaquant un oracle gratuit pour l’énumération de comptes. Une réponse générique invalid_credentials, avec une charge de travail comparable dans les deux cas, ne lui apprendra rien.
Une connexion réussie est également le moment de réfléchir au rate limiting, au verrouillage après des échecs répétés et aux défis d’authentification multi-facteurs. Toutes ces étapes interviennent avant la création de l’enregistrement de session.
Que stocker dans une session
Un enregistrement de session doit être un pointeur, pas une photocopie. La tentation d’y injecter l’objet utilisateur complet est forte, et c’est généralement une erreur.
Stockez l’user id et laissez la requête charger le reste. Si vous mettez l’utilisateur en cache pour des raisons de performance, donnez-lui une durée de vie courte et invalidez-le en cas de modification. En effet, une copie obsolète dans une session longue produit des bugs difficiles à reproduire : un administrateur dont le grade a été rétrogradé conserve son ancien rôle jusqu’à sa prochaine connexion.
Les éléments pertinents à conserver côté serveur incluent l’user id, un rôle ou un tenant id (lorsqu’il est court et change rarement), un jeton CSRF, un indicateur “se souvenir de moi”, ainsi qu’un état d’interface utilisateur éphémère comme un message flash ou une cible de redirection après connexion. Évitez de stocker des objets volumineux, des secrets, des jetons pour d’autres services, ou tout élément que vous ne souhaiteriez pas voir apparaître dans un dump de débogage.
declare module "express-session" {
interface SessionData {
userId: string;
tenantId: string;
csrfToken: string;
flash?: { type: "info" | "error"; message: string };
}
}
Si la charge utile de la session dépasse quelques kilo-octets, c’est le signe que les données doivent se trouver dans la base de données, indexées par l’utilisateur, et chargées à la demande. Des sessions légères sont plus rapides à sérialiser, moins coûteuses à stocker et plus simples à appréhender.
Construction de la pile de middleware
La gestion des sessions s’exprime idéalement sous la forme d’une petite chaîne ordonnée : analyser le cookie, charger la session, attacher l’utilisateur, puis protéger les routes sécurisées. Chaque élément remplit une seule fonction, ce qui rend l’ensemble testable.
import cookieParser from "cookie-parser";
import session from "express-session";
app.use(cookieParser());
app.use(
session({
name: "__Host-sid",
secret: process.env.SESSION_SECRET!,
store,
resave: false, // do not rewrite unchanged sessions
saveUninitialized: false, // do not create a session for anonymous visitors
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 1000 * 60 * 60 * 12,
},
})
);
app.use(loadUser()); // attach req.user when a session exists
app.use(csrf()); // issue and verify CSRF tokens
app.use("/api", routes); // handlers can call requireAuth() themselves
Deux options sont souvent sources de confusion. resave: false empêche le middleware de réécrire la session dans le store à chaque requête lorsque rien n’a changé, ce qui économise un aller-retour. saveUninitialized: false empêche la création d’une session pour chaque visiteur anonyme, évitant ainsi que le store ne se remplisse d’enregistrements vides et qu’un cookie ne soit défini avant même que l’utilisateur n’ait effectué d’action. Ce sont deux paramètres que vous souhaiterez presque toujours activer.
L’ordre est primordial. Le parseur de cookies doit s’exécuter avant le middleware de session, le middleware de session avant tout élément qui lit req.session, et le chargeur d’utilisateur avant toute route qui vérifie req.user. Le middleware de protection peut être global ou attaché par route ; l’attacher par route permet de garder les points de terminaison publics accessibles par défaut.
Le SameSite en détail
SameSite est l’attribut que les gens comprennent le plus souvent mal, il est donc important de comprendre ce que chaque valeur autorise réellement.
Strict n’envoie jamais le cookie lors d’une requête dont le site diffère de celui qui l’a défini. Cliquer sur un lien dans un e-mail vers votre application arrivera sans la session, l’utilisateur peut donc sembler déconnecté lors de cette première navigation, puis connecté après un rafraîchissement. C’est le paramètre le plus sûr et il convient parfaitement aux outils d’administration à haut risque.
Lax envoie le cookie lors des navigations de premier niveau utilisant des méthodes sûres, mais pas lors de POST, d’images ou d’iframes cross-site. Cela couvre le cas courant où l’on suit un lien tout en restant connecté, tout en bloquant l’envoi classique de formulaires CSRF. C’est le choix par défaut raisonnable pour la plupart des applications web et le défaut du navigateur lorsque l’attribut est omis dans les versions modernes de Chrome.
None envoie le cookie pour chaque requête cross-site et nécessite Secure. Cela n’est nécessaire que pour des flux cross-site authentiques, comme un tunnel de paiement intégré ou un widget servi depuis une origine différente. Chaque utilisation de None élargit votre surface d’attaque CSRF ; ajoutez donc des jetons au niveau de l’application et confirmez que le flag Secure est présent, sinon le navigateur rejettera purement et simplement le cookie.
Un point subtil : SameSite compare des sites, et non des origines, et la définition d’un site est le domaine enregistrable. app.example.com et api.example.com sont le même site, donc un sous-domaine compromis n’est pas protégé du tout par SameSite. C’est une autre raison pour laquelle le préfixe de cookie __Host- et une hygiène stricte des sous-domaines sont importants.
Créer son propre système ou utiliser une bibliothèque
Il est tentant d’implémenter les sessions manuellement : définir un cookie, maintenir un Map, puis effectuer la recherche. Pour apprendre, c’est excellent. Pour la production, c’est généralement une erreur, car les détails cruciaux sont précisément ceux que l’on a tendance à oublier.
Une bibliothèque mature telle que express-session pour Node, le framework de session de Django ou le session store de Rails vous offre des identifiants signés, des configurations de cookies sécurisées par défaut, des stores interchangeables, la régénération d’identifiants et la gestion de l’expiration. Vous devez tout de même comprendre chacun de ces mécanismes — c’est tout l’objet de ce guide — mais vous ne devriez pas être la seule personne à y avoir réfléchi.
Si vous décidez de construire le vôtre, voici la checklist minimale : un identifiant CSPRNG d’au moins 128 bits, une recherche en temps constant, des cookies HttpOnly, Secure et SameSite, la rotation de l’identifiant lors de la connexion, l’expiration côté serveur et un processus de suppression lors de la déconnexion. S’il manque l’un de ces éléments, vous avez une vulnérabilité, pas un système de session.
Tester l’authentification de session
L’authentification est un aspect critique de la sécurité ; elle mérite donc des tests qui vérifient les cas d’échec, et pas seulement le “happy path”.
import request from "supertest";
import app from "../app.js";
test("protected route rejects anonymous requests", async () => {
await request(app).get("/api/me").expect(401);
});
test("login sets an HttpOnly session cookie", async () => {
const res = await request(app)
.post("/login")
.send({ email: "[email protected]", password: "correct-horse" })
.expect(200);
const cookie = res.headers["set-cookie"][0];
expect(cookie).toContain("HttpOnly");
expect(cookie).toContain("SameSite=Lax");
});
test("logout invalidates the session", async () => {
const agent = request.agent(app);
await agent.post("/login").send(credentials).expect(200);
await agent.get("/api/me").expect(200);
await agent.post("/logout").expect(204);
await agent.get("/api/me").expect(401);
});
L’utilisation d’un agent qui préserve les cookies permet à un seul test de simuler le comportement d’un navigateur réel. Vérifiez que l’identifiant de session change après la connexion, qu’une session expirée est rejetée et qu’un cookie altéré est ignoré. Ces tests permettent de détecter des régressions qu’un test manuel passerait inaperçu.
Les attributs de cookie essentiels
L’identifiant de session n’est aussi sûr que le cookie qui le transporte. Ces attributs font la différence entre une conception solide et une conception vulnérable.
- HttpOnly — le cookie est invisible pour
document.cookie. C’est le flag le plus important, car il neutralise le vol de session via XSS. Activez-le systématiquement. - Secure — le navigateur n’envoie le cookie que via HTTPS. Sans cela, un attaquant sur le réseau peut lire l’identifiant. Activez-le systématiquement en production, et gardez à l’esprit que les cookies
Securesont ignorés en HTTP simple, ce qui impacte le développement local. - SameSite — contrôle l’envoi du cookie entre différents sites.
Strictn’envoie jamais le cookie lors de requêtes cross-site, ce qui est l’option la plus sûre mais casse les liens entrants qui vous permettraient normalement de rester connecté.Laxl’envoie lors de navigations de haut niveau (comme le clic sur un lien), et constitue le choix par défaut idéal pour la plupart des applications.Nonel’envoie partout et nécessiteSecure. - Path — limite le cookie à un préfixe d’URL.
Path=/est le choix typique. Le restreindre à/appréduit légèrement l’exposition, mais apporte rarement un bénéfice suffisant pour justifier des comportements imprévus. - Domain — contrôle les hôtes qui reçoivent le cookie. L’omettre maintient le cookie sur l’hôte exact, ce qui est l’option la plus sûre. Définir un domaine parent partage la session entre les sous-domaines, augmentant ainsi la surface d’attaque en cas de compromission d’un sous-domaine.
- Max-Age et Expires — déterminent quand le cookie doit être supprimé. Un cookie de session sans aucun de ces deux attributs persiste jusqu’à la fermeture du navigateur, ce qui n’est souvent pas ce que les utilisateurs attendent sur mobile. Un
Max-Ageexplicite est plus clair. - Préfixe
__Host-— nommer un cookie__Host-sidimposeSecure,Path=/et l’absence deDomain, ce qui empêche un sous-domaine d’écraser votre cookie de session.
HTTP/1.1 200 OK
Set-Cookie: __Host-sid=s%3A9f2c...; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=86400
Choisir un store de sessions
L’endroit où sont stockées vos sessions est la décision qui impacte le plus la scalabilité de votre application.
La mémoire interne (in-process memory) est le choix par défaut de nombreux frameworks et convient parfaitement pour une application sur une seule instance ou un prototype. Cependant, les données disparaissent au redémarrage et ne peuvent pas être partagées, ce qui devient problématique dès que vous lancez plus d’un processus.
Redis est le choix standard pour la production. Il est rapide, gère nativement l’expiration des clés et est partagé par toutes les instances. Les sessions sont de petits enregistrements clé-valeur, ce qui correspond exactement aux points forts de Redis. Un Redis managé est peu coûteux et élimine la charge opérationnelle.
Une base de données relationnelle fonctionne bien si vous utilisez déjà PostgreSQL ou MySQL et que vous voulez que les sessions survivent à une panne de Redis. Vous bénéficiez de la durabilité et d’une inspection facile via SQL, au prix d’une recherche plus lente, à moins que la table ne soit petite et indexée par id.
Une table de sessions dédiée est courante dans des frameworks comme Django et Rails. Le store est une table comprenant une clé de session, un payload sérialisé et une colonne d’expiration. Un index sur la clé et une tâche de nettoyage périodique sont les seules opérations de maintenance requises.
CREATE TABLE sessions (
id text PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
data jsonb NOT NULL DEFAULT '{}'::jsonb,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX sessions_expires_idx ON sessions (expires_at);
La mémoire ne passe pas à l’échelle
Il est important d’expliquer clairement pourquoi les sessions en mémoire sont un bug en production, car la panne est intermittente et déroutante.
Imaginez deux instances d’application derrière un load balancer sans store partagé. Un utilisateur se connecte et arrive sur l’instance A, qui stocke la session dans sa propre mémoire. La requête suivante est routée vers l’instance B, qui n’a aucune trace de l’id ; l’utilisateur semble donc déconnecté. S’il se reconnecte, il peut rebondir entre les deux. L’utilisateur constate des déconnexions aléatoires, tandis que les logs montrent une session qui existe sur un nœud et pas sur l’autre.
Vous pouvez masquer ce problème avec des sticky sessions, où le load balancer lie un client à une seule instance. Cela aide, mais c’est fragile : un déploiement, un crash ou un événement d’autoscaling supprime le nœud et toutes les sessions qu’il contient. Les sticky sessions freinent également le scaling horizontal et compliquent les canary releases.
La véritable solution est l’utilisation d’un store partagé. Une fois que les sessions résident dans Redis ou une base de données, n’importe quelle instance peut répondre à n’importe quelle requête, les déploiements deviennent invisibles pour les utilisateurs connectés et vous pouvez augmenter la capacité librement. N’utilisez la mémoire que pour le développement local, et rendez le store configurable pour que la production ne l’utilise jamais par accident.
Fixation et rotation de session
La fixation de session est une attaque où l’attaquant obtient un identifiant de session que la victime utilisera, puis attend que la victime s’authentifie pour profiter de la session désormais privilégiée. Le mode de diffusion classique est un lien piégé tel que ?sid=attacker-known-id sur un site qui accepte un identifiant de session fourni par le client.
La défense est simple et obligatoire : régénérez l’identifiant de session dès que le niveau de privilège change. Cela signifie lors de la connexion, après une réinitialisation de mot de passe, lors du passage à une vue administrateur et après toute authentification renforcée (step-up authentication). L’identifiant pré-connexion est supprimé et la copie de l’attaquant devient inutile.
req.session.regenerate((err) => {
if (err) return next(err);
// A brand new id now identifies this session.
req.session.userId = user.id;
res.json({ ok: true });
});
La rotation présente un second avantage : elle empêche la réutilisation d’un identifiant de session dans le temps. Un identifiant valide depuis plusieurs mois est une cible bien plus précieuse qu’un identifiant qui change à chaque connexion. Certains frameworks effectuent également une rotation via un minuteur, ce qui limite la fenêtre d’utilisation d’un identifiant compromis.
N’acceptez jamais un identifiant de session provenant d’une query string, du corps d’une requête ou d’un header personnalisé. La seule source doit être le cookie défini par le serveur lui-même, et ce, uniquement après avoir validé que l’identifiant existe dans le store.
Expirations et délais d’inactivité
Les sessions ne doivent pas être éternelles. Deux types de compteurs sont essentiels, et les systèmes robustes utilisent les deux.
Un délai d’inactivité (idle timeout) expire une session après une période sans activité, généralement entre 15 et 60 minutes pour les applications sensibles. Chaque requête arrivant avant l’expiration réinitialise le délai : ainsi, un utilisateur actif reste connecté, tandis qu’un ordinateur portable abandonné perd l’accès. C’est la défense principale contre le vol d’un cookie stocké sur un appareil.
Une durée de vie absolue (absolute lifetime) limite l’âge total d’une session, quelle que soit l’activité, couramment de 8 à 24 heures (et moins pour les applications à haut risque). Cela impose une ré-authentification périodique et limite la durée pendant laquelle une session compromise peut être exploitée, même si elle reste active.
Stockez la date d’expiration avec la session et vérifiez-la à chaque consultation, et pas seulement dans le cookie. Le Max-Age d’un cookie n’est qu’une indication côté client qu’un attaquant peut ignorer ; l’expiration côté serveur est la seule règle réelle. Lors de la déconnexion, supprimez l’enregistrement plutôt que de simplement le marquer comme expiré, afin d’éviter que le stockage n’accumule des entrées obsolètes.
const session = await store.get(id);
if (!session || session.expiresAt < Date.now()) {
await store.delete(id);
return next(); // anonymous
}
CSRF : le prix de l’authentification par cookie
Comme les navigateurs joignent les cookies automatiquement, une page provenant d’une autre origine peut amener votre navigateur à envoyer une requête authentifiée à votre insu. C’est ce qu’on appelle le cross-site request forgery (ou falsification de requête intersite) : un site malveillant soumet un formulaire vers https://bank.example/transfer et votre cookie de session est envoyé avec, rendant la requête légitime en apparence.
SameSite est la première ligne de défense. SameSite=Lax empêche l’envoi des cookies lors de requêtes POST intersites, ce qui bloque l’attaque classique. Cependant, SameSite seul ne constitue pas une stratégie complète : les navigateurs obsolètes, certaines configurations de sous-domaines et certains flux intersites légitimes nécessitent toujours une protection au niveau de l’application.
Deux modèles permettent de couvrir le reste :
- Le jeton de synchronisation (Synchronizer token). Le serveur stocke un jeton aléatoire dans la session et l’insère dans les formulaires ou l’expose dans un en-tête de réponse. Les requêtes non sécurisées doivent renvoyer ce jeton, et le serveur les compare en temps constant. Comme la page de l’attaquant ne peut pas lire le jeton, elle ne peut pas forger de requête valide.
- Le cookie à double soumission (Double-submit cookie). Le serveur définit une valeur aléatoire dans un cookie non-HttpOnly et le client envoie cette même valeur dans un en-tête. Le serveur vérifie que les deux correspondent. Cette méthode est sans état (stateless) et facile à mettre en œuvre, mais elle repose sur l’incapacité de l’attaquant à définir des cookies pour votre domaine ; associez-la donc au préfixe
__Host-.
const sent = req.get("x-csrf-token") ?? "";
const expected = req.session.csrfToken;
const ok =
sent.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sent), Buffer.from(expected));
if (!ok) return res.status(403).json({ error: "csrf_failed" });
Appliquez une protection CSRF à toutes les méthodes modifiant l’état — POST, PUT, PATCH, DELETE — et exemptez GET, HEAD et OPTIONS, qui ne doivent jamais modifier l’état. Vérifiez également l’en-tête Origin sur les requêtes non sécurisées comme signal supplémentaire et léger.
Cookies signés versus cookies chiffrés
Un cookie peut transporter des données en soi, et pas seulement un identifiant, à condition de les protéger. Deux mécanismes sont couramment utilisés, et ils répondent à des problèmes différents.
Un cookie signé ajoute un code d’authentification de message (MAC) basé sur une clé, permettant ainsi au serveur de détecter toute altération. Un utilisateur ne peut pas modifier role=member en role=admin car la signature ne correspondrait plus. Cependant, la signature ne cache rien : la charge utile (payload) est en base64 et peut être lue par toute personne possédant le cookie. La signature garantit l’intégrité, pas la confidentialité.
Un cookie chiffré (souvent appelé “sealed cookie”) masque et authentifie simultanément la charge utile. Le serveur peut ainsi stocker une petite quantité de données de session directement dans le cookie, supprimant ainsi la nécessité d’une requête vers le store, tout en gardant ces données opaques pour le client. Des frameworks comme cookie-session et les cookies chiffrés de Rails adoptent cette approche.
Le compromis est le même que pour les JWT : un cookie autonome est plus difficile à révoquer et augmente la taille de chaque requête. Un juste milieu utile est l’approche hybride : conserver une session côté serveur pour l’identité et les permissions, et utiliser un cookie signé à courte durée de vie pour un petit indicateur (flag) non sensible. Quel que soit votre choix, ne placez jamais de secrets, de rôles que vous ne vérifiez pas à nouveau, ou de gros objets dans un cookie.
Mise à l’échelle des sessions sur plusieurs serveurs
Une fois que le store est partagé, les enjeux restants sont la performance et l’exploitation.
Gardez des sessions légères. Une session doit contenir des identifiants, et non des objets complets. Stocker une copie du document utilisateur signifie que chaque modification de profil doit mettre à jour chaque session, et les données obsolètes provoquent des bugs déroutants. Stockez userId et chargez l’utilisateur, ou mettez-le brièvement en cache.
Lisez efficacement. Un GET Redis par requête prend quelques microsecondes, mais à haut volume, cela reste un saut réseau. De nombreuses stacks mettent la session en cache pour la durée d’une seule requête afin que plusieurs middlewares ne sollicitent pas chacun le store.
Gérez l’indisponibilité du store. Décidez si une panne de Redis doit déconnecter tout le monde ou bloquer l’accès aux routes protégées. Le blocage est plus sûr : considérez un store injoignable comme une “absence de session” pour les points de terminaison authentifiés, et renvoyez une erreur claire plutôt que d’accorder l’accès silencieusement.
Nettoyez vos données. Définissez un TTL sur les clés Redis et un DELETE FROM sessions WHERE expires_at < now() périodique pour les stores en base de données. Sans nettoyage, le store croît indéfiniment et les recherches ralentissent.
Évitez les sticky sessions lorsque vous disposez d’un store partagé. Elles ajoutent une complexité opérationnelle sans bénéfice réel et rendent les déploiements sans interruption (zero-downtime) plus difficiles. Si vous ne pouvez vraiment pas partager de store, les sticky sessions sont un palliatif, pas une architecture.
Sessions vs JWTs
Cette comparaison revient constamment, et la réponse honnête est qu’elles résolvent des problèmes similaires avec des compromis différents.
| Préoccupation | Session côté serveur | JWT |
|---|---|---|
| Emplacement de l’état | Stockage serveur | À l’intérieur du token |
| Révocation | Immédiate | Difficile avant l’expiration |
| Recherche par requête | Requise | Aucune |
| Données sensibles côté client | Jamais | À éviter ; c’est lisible |
| Mise à l’échelle (Scaling) | Nécessite un stockage partagé | Sans état (stateless) par design |
| Risque CSRF navigateur | Oui | Uniquement si basé sur des cookies |
| Meilleur usage | Applications web first-party | API, services, mobile |
Les sessions l’emportent sur le contrôle. Vous pouvez révoquer un seul appareil, lister les sessions actives, forcer la déconnexion partout et modifier des permissions qui prendront effet dès la requête suivante. Les JWT l’emportent sur l’aspect stateless : n’importe quel service peut vérifier un token sans recherche partagée, ce qui est attractif pour les microservices et les API où l’émetteur et le vérificateur sont des systèmes différents.
Une architecture courante et judicieuse utilise les deux. Le navigateur reçoit un cookie de session HttpOnly qui fait référence à une session côté serveur. Cette session peut contenir un JWT à courte durée de vie utilisé pour appeler des services internes. L’expérience utilisateur est celle d’une connexion classique, et les appels internes restent stateless. Pour approfondir l’autre approche, consultez le guide sur les JWT.
Audit et observabilité
Les sessions constituent une frontière de sécurité, et toute frontière doit être observable. Journalisez les événements importants, mais jamais les secrets.
Enregistrez les connexions réussies et échouées avec l’identifiant de l’utilisateur, une source approximative telle que l’IP et l’user agent, ainsi qu’un horodatage. Enregistrez la création, la rotation et la destruction des sessions. Ne journalisez jamais l’identifiant de session lui-même, le mot de passe ou le jeton CSRF ; un identifiant de session dans un fichier de log est un identifiant d’authentification dans un fichier de log. Si vous devez établir une corrélation, journalisez plutôt un hash court de l’identifiant.
logger.info({
event: "session.created",
userId: user.id,
ip: req.ip,
userAgent: req.get("user-agent"),
});
Offrez aux utilisateurs un moyen de visualiser et de révoquer leurs sessions actives. Une page « sessions » listant les appareils avec un bouton de déconnexion transforme une suspicion de compromission en une correction en un clic, et c’est une fonctionnalité que les utilisateurs attendent de plus en plus. Pour les administrateurs, configurez des alertes en cas de « voyages impossibles », de pic de connexions échouées ou de création de nombreuses sessions à partir d’une seule IP.
Bonnes pratiques
- Générez les identifiants de session avec un générateur aléatoire cryptographiquement sécurisé, jamais avec un compteur ou une valeur fournie par l’utilisateur.
- Configurez
HttpOnly,Secure,SameSite=Laxet unMax-Ageexplicite sur le cookie de session ; envisagez l’utilisation du préfixe__Host-. - Régénérez l’identifiant de session lors de la connexion et après chaque changement de privilèges pour contrer la fixation de session.
- Utilisez un store partagé tel que Redis ou une base de données en production ; ne vous appuyez jamais sur la mémoire du processus entre différentes instances.
- Imposez à la fois un délai d’expiration d’inactivité (idle timeout) et une durée de vie absolue côté serveur, et pas seulement dans le cookie.
- Détruisez l’enregistrement lors de la déconnexion et nettoyez les sessions expirées périodiquement.
- Ajoutez une protection CSRF à chaque route modifiant l’état et vérifiez l’en-tête
Origin. - Gardez les payloads de session légers ; stockez les identifiants et chargez le reste des données.
- Adoptez une approche “fail closed” (refus par défaut) lorsque le store de session est inaccessible sur les routes protégées.
- Demandez une ré-authentification pour les actions sensibles, comme le changement de mot de passe ou l’ajout d’un mode de paiement.
Erreurs courantes
- Stocker les sessions en mémoire process tout en exécutant plusieurs instances.
- Oublier
HttpOnly, exposant ainsi l’identifiant de session aux attaques XSS. - Laisser
SameSite=NonesansSecure, ce qui conduit les navigateurs à rejeter silencieusement le cookie. - Réutiliser l’identifiant de session après la connexion au lieu de le renouveler (rotation).
- Accepter un identifiant de session via un paramètre de requête ou le corps de la requête.
- Placer les rôles et les permissions dans le cookie et s’y fier sans effectuer de nouvelle vérification.
- Définir un cookie
Max-Agesans jamais expirer l’enregistrement côté serveur. - Retourner un code 200 avec un corps d’erreur pour une requête non authentifiée au lieu d’un code 401.
- Négliger la protection CSRF en pensant que “SameSite s’en occupe”.
- Laisser le store de sessions croître indéfiniment sans TTL ni tâche de nettoyage.
Et après ?
L’authentification par session enseigne les fondamentaux sur lesquels reposent tous les autres schémas d’authentification : un identifiant, une recherche, une expiration et un moyen de révocation. La comparaison naturelle est le JWT, où les claims voyagent avec le client et où la révocation devient la partie complexe. Si vous avez besoin d’une connexion via un tiers ou d’un accès délégué, OAuth 2.0 est le standard. Pour comprendre le cookie lui-même — Set-Cookie, SameSite et les règles d’en-tête — consultez le guide HTTP. Enfin, pour intégrer le middleware dans un serveur réel, le guide Node.js détaille l’environnement d’exécution qui sous-tend le tout.