L'authentification WebSocket expliquée
Méthodes d’authentification WebSocket expliquées : jetons en query string, cookies, sous-protocoles et premier message, plus renouvellement et autorisation par message.
L’API WebSocket du navigateur ne prend pas en charge la définition d’en-têtes HTTP personnalisés — le constructeur WebSocket n’accepte qu’une URL et un tableau de sous-protocoles optionnel — de sorte que l’authentification doit s’effectuer via l’un des trois mécanismes suivants : un token dans la chaîne de requête lors du handshake HTTP d’upgrade, un cookie de session que le navigateur envoie automatiquement, ou des identifiants dans le premier message après l’ouverture de la connexion. C’est cette contrainte qui distingue l’auth WebSocket de l’auth REST, où il suffit d’attacher un en-tête Authorization: Bearer à chaque requête.
Si vous avez déjà mis en œuvre une auth REST avec des JWT ou des cookies de session, vous en connaissez déjà les composants. Ce qui change avec les WebSockets, c’est le mécanisme de transmission et la durée de vie. Une requête REST s’authentifie une fois et se termine. Une connexion WebSocket reste ouverte pendant des minutes, voire des heures, ce qui signifie que le token valide au moment du handshake peut expirer, être révoqué ou perdre ses permissions pendant que la socket est toujours ouverte. Cet article couvre chaque pattern handshake-vs-premier-message avec du code fonctionnel pour le navigateur et Node.js, propose une règle de décision pour en choisir un, et traite deux aspects que la plupart des guides ignorent : le renouvellement des tokens sur les connexions longue durée et l’autorisation par message.
Points clés à retenir
- Le constructeur
WebSocketdu navigateur n’accepte qu’une URL et un tableau de sous-protocoles, il est donc impossible d’envoyer un en-têteAuthorization— l’auth se déplace vers la chaîne de requête, un cookie, l’en-têteSec-WebSocket-Protocol, ou le premier message. - L’authentification WebSocket est un contrôle continu, pas un événement ponctuel : les connexions étant longue durée, l’autorisation par message et le renouvellement des tokens sont des exigences de production, pas des mesures de sécurité optionnelles.
- Utilisez le rafraîchissement de token in-band lorsque la socket porte des abonnements avec état ; utilisez la fermeture-reconnexion lorsque la connexion est sans état et que le contexte est peu coûteux à reconstruire.
- La claim JWT
expest un NumericDate en secondes depuis l’époque Unix, donc la planification du rafraîchissement côté client doit convertir viaexp * 1000avant de comparer àDate.now(). - Un mode d’échec silencieux courant est une socket qui se ferme à l’expiration du token pendant que l’interface continue d’afficher un état « connecté » et abandonne silencieusement tous les messages suivants — visible dans le rejeu de session, invisible dans les logs serveur.
Pourquoi l’authentification WebSocket est différente
L’authentification WebSocket est contrainte au niveau du navigateur : l’API JavaScript WebSocket ne vous offre aucun moyen d’attacher un en-tête personnalisé au handshake d’ouverture. Chaque connexion WebSocket commence par un GET HTTP avec un en-tête Upgrade: websocket, mais le navigateur contrôle entièrement cette requête — votre code ne fournit que l’URL et une liste optionnelle de sous-protocoles. Le protocole WebSocket lui-même est explicite sur le fait qu’il ne résout pas ce problème pour vous : selon la RFC 6455 §10.5, le protocole « ne prescrit aucune méthode particulière permettant aux serveurs d’authentifier les clients lors du handshake WebSocket. »
La contrainte d’absence d’en-tête dans le navigateur laisse quatre mécanismes pratiques, qui contournent tous cette limitation :
- Token en paramètre de requête — placer le token dans l’URL de connexion.
- Cookie/session — laisser le navigateur envoyer un cookie de session existant avec la requête d’upgrade.
- Sous-protocole
Sec-WebSocket-Protocol— faire passer le token dans le tableau de sous-protocoles. - Auth par premier message — ouvrir la socket sans authentification, puis envoyer les identifiants comme premier message.
La suite de cette section détaille chaque méthode avec le code client navigateur et le code serveur Node.js utilisant la bibliothèque ws (version stable actuelle : 8.x).
Discover how at OpenReplay.com.
Token en paramètre de requête
Placez le token dans l’URL de connexion et validez-le lors de l’upgrade HTTP, avant d’allouer les ressources de connexion. C’est la méthode la plus simple et la plus répandue, et son avantage est le rejet rapide — le serveur peut retourner un 401 avant même que la socket ne soit établie.
// Client navigateur
const token = localStorage.getItem('authToken');
const socket = new WebSocket(`wss://api.example.com/ws?token=${encodeURIComponent(token)}`);
// Serveur Node.js — ws@8.21.0
import { WebSocketServer } from 'ws';
import { verify } from 'jsonwebtoken';
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (req, socket, head) => {
const { searchParams } = new URL(req.url, 'wss://api.example.com');
const token = searchParams.get('token');
try {
const user = verify(token, process.env.JWT_SECRET);
wss.handleUpgrade(req, socket, head, (ws) => {
ws.user = user;
wss.emit('connection', ws, req);
});
} catch {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
}
});
Risque concret : un token dans l’URL WebSocket est enregistré par défaut dans les logs d’accès de nginx et Apache — les deux formats de log par défaut (combined / %r) incluent la ligne de requête complète avec sa chaîne de requête — peut apparaître dans l’historique du navigateur, et peut fuiter via l’en-tête Referer si la page navigue après la connexion. Les tokens de courte durée (5 à 15 minutes est une convention du secteur plutôt qu’une recommandation documentée par l’OWASP) réduisent cette exposition sans l’éliminer entièrement.
Cookie / Session
L’auth par cookie réutilise une session déjà établie via HTTP : le navigateur attache automatiquement les cookies du même domaine à la requête d’upgrade, ce qui ne nécessite aucun code d’auth côté client. C’est la méthode la moins contraignante lorsque votre endpoint WebSocket partage un domaine avec l’application qui a authentifié l’utilisateur.
// Client navigateur — aucune gestion de token nécessaire ; le cookie est transmis automatiquement
const socket = new WebSocket('wss://app.example.com/ws');
// Serveur Node.js — ws@8.21.0
import { parse } from 'cookie';
server.on('upgrade', (req, socket, head) => {
const origin = req.headers.origin;
if (origin !== 'https://app.example.com') {
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
return socket.destroy();
}
const cookies = parse(req.headers.cookie || '');
const session = sessionStore.get(cookies.sid);
if (!session) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
return socket.destroy();
}
wss.handleUpgrade(req, socket, head, (ws) => {
ws.user = session.user;
wss.emit('connection', ws, req);
});
});
Risque concret : l’auth WebSocket par cookie nécessite à la fois la validation de l’en-tête Origin côté serveur et l’attribut de cookie SameSite=Strict ou SameSite=Lax. Comme le décrit la documentation MDN sur SameSite, SameSite contrôle si un cookie est envoyé lors de requêtes cross-site ; SameSite=None (requis pour les cookies véritablement cross-site) réexpose la surface d’attaque CSRF que SameSite a été introduit pour combler. Les cookies échouent également entre domaines différents, ce qui est la principale raison pour laquelle les équipes se tournent vers les deux méthodes suivantes.
Sous-protocole Sec-WebSocket-Protocol
Vous pouvez passer un token comme valeur de sous-protocole, car le tableau de sous-protocoles est le seul élément du handshake que le navigateur expose. L’en-tête Sec-WebSocket-Protocol a été conçu pour négocier des sous-protocoles au niveau applicatif, pas pour transporter des tokens d’authentification ; l’utiliser pour l’auth est un contournement qui fonctionne dans tous les navigateurs majeurs mais doit être considéré comme un dernier recours.
Les valeurs de sous-protocole doivent être des valeurs token valides sans caractères séparateurs, donc le token doit être encodé en base64url sans rembourrage — les caractères / et = du base64 standard ne sont pas valides ici. L’alphabet base64url est défini dans la RFC 4648 §5.
// Client navigateur — token comme valeur de sous-protocole base64url sans rembourrage
const token = localStorage.getItem('authToken'); // déjà en base64url, sans rembourrage
const socket = new WebSocket('wss://api.example.com/ws', ['auth.bearer', token]);
// Serveur Node.js — ws@8.21.0
const wss = new WebSocketServer({
noServer: true,
handleProtocols: (protocols) => {
// protocols est un Set ; la deuxième entrée est notre token
const [, token] = [...protocols];
return validate(token) ? 'auth.bearer' : false;
},
});
Risque concret : comme la méthode par paramètre de requête, la valeur du sous-protocole peut se retrouver dans les logs qui enregistrent les en-têtes du handshake, et l’exigence d’encodage (base64url sans rembourrage) est facile à manquer. Réservez cette méthode aux cas où les cookies sont bloqués cross-domain et où l’exposition des paramètres de requête est inacceptable.
Authentification par premier message
La connexion s’ouvre sans authentification et le client envoie ses identifiants comme premier message ; le serveur les valide avant de traiter quoi que ce soit d’autre. Cela maintient les tokens hors des URLs et des logs, au prix d’un protocole de mise en file d’attente et de timeout que vous devez construire vous-même.
// Client navigateur — mettre en file d'attente les messages applicatifs jusqu'à la réussite de l'AUTH
const socket = new WebSocket('wss://api.example.com/ws');
const queue = [];
let authed = false;
socket.onopen = () => {
socket.send(JSON.stringify({ type: 'AUTH', token: localStorage.getItem('authToken') }));
};
socket.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'AUTH_OK') {
authed = true;
queue.forEach((m) => socket.send(m));
queue.length = 0;
}
};
function send(data) {
const m = JSON.stringify(data);
authed ? socket.send(m) : queue.push(m);
}
// Serveur Node.js — ws@8.21.0
wss.on('connection', (ws) => {
const timer = setTimeout(() => ws.close(4001, 'auth timeout'), 7000);
ws.once('message', (raw) => {
const msg = JSON.parse(raw);
if (msg.type !== 'AUTH') return ws.close(4002, 'expected AUTH');
try {
ws.user = verify(msg.token, process.env.JWT_SECRET);
clearTimeout(timer);
ws.send(JSON.stringify({ type: 'AUTH_OK' }));
} catch {
ws.close(4003, 'invalid token');
}
});
});
Risque concret : un serveur acceptant des connexions WebSocket non authentifiées doit imposer un délai d’auth strict — généralement 5 à 10 secondes, une convention sans base dans les spécifications — et fermer toute connexion qui n’envoie pas d’identifiants valides dans ce délai. Sans cela, un attaquant peut épuiser les limites de connexion avec des sockets ouvertes mais non authentifiées. La valeur appropriée dépend de votre temps de round-trip au p95 : 5 secondes convient aux connexions à faible latence, 10 secondes est plus prudent pour les clients mobiles et à haute latence.
Comparaison des méthodes et règle de décision
Choisissez la méthode en fonction des contraintes de votre déploiement, et non pas selon celle qui est « la plus sécurisée » dans l’absolu — les quatre sont sécurisées lorsqu’elles sont correctement implémentées. Le tableau ci-dessous résume les compromis ; la règle de décision qui suit permet de trancher dans les cas courants.
| Méthode | Exposition du token | Risque CSRF | Cross-domain | Complexité | À utiliser quand |
|---|---|---|---|---|---|
| Token en paramètre de requête | Logs, historique, Referer | Aucun | Oui | Faible | Vous contrôlez les logs serveur et souhaitez la solution la plus simple |
| Cookie / session | Aucune (cookie HttpOnly) | Oui (atténuer avec SameSite + Origin) | Non | Faible | Même domaine, session existante |
| En-tête de sous-protocole | Logs du handshake | Aucun | Oui | Moyenne | Cookies bloqués, paramètres de requête inacceptables |
| Premier message | Aucune | Aucun | Oui | Élevée | Les tokens doivent rester hors des URLs |
La règle de décision : utilisez les tokens en paramètre de requête lorsque vous contrôlez les logs serveur et avez besoin de l’implémentation la plus simple ; utilisez les cookies lorsque vous êtes sur le même domaine et disposez déjà d’une session ; utilisez l’auth par premier message lorsque vous devez maintenir les tokens hors des URLs et pouvez absorber la complexité de la mise en file d’attente ; utilisez l’en-tête de sous-protocole uniquement lorsque les cookies sont bloqués cross-domain et que l’exposition des paramètres de requête est inacceptable — c’est une approche non standard qui doit rester un dernier recours. Les équipes qui préfèrent ne pas gérer tout cela se tournent souvent vers une plateforme temps réel managée, qui prend en charge le cycle de vie des tokens dans le cadre du service.
Renouvellement des tokens pour les connexions longue durée
Une connexion WebSocket survit à son token, vous devez donc définir une stratégie de renouvellement dès le départ. Il en existe deux : rafraîchir le token in-band sur la socket ouverte, ou fermer et se reconnecter avec un nouveau token. Utilisez le rafraîchissement de token in-band — envoyer un nouveau token sur la connexion existante — lorsque la socket porte des abonnements avec état ou des opérations en attente qui seraient perdues lors d’une reconnexion ; utilisez la fermeture-reconnexion lorsque la connexion est sans état et que le client peut rétablir le contexte à moindre coût sans perte de données.
Le client planifie le rafraîchissement à partir de l’expiration du JWT lui-même. La claim exp est un NumericDate en secondes depuis l’époque Unix selon la RFC 7519 §4.1.4, vous devez donc multiplier par 1000 avant de comparer à Date.now().
// Navigateur — planifier le rafraîchissement in-band 60s avant l'expiration, avec reconnexion en fallback
class WebSocketAuthManager {
constructor(url) {
this.url = url;
this.connect();
}
connect() {
this.ws = new WebSocket(`${this.url}?token=${getToken()}`);
this.ws.onopen = () => this.scheduleRefresh();
this.ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'TOKEN_REFRESH_OK') this.scheduleRefresh();
};
}
scheduleRefresh() {
const { exp } = parseJwt(getToken()); // exp est en secondes
const fireAt = exp * 1000 - Date.now() - 60_000; // 60s avant l'expiration
clearTimeout(this.timer);
this.timer = setTimeout(() => this.refresh(), Math.max(0, fireAt));
}
async refresh() {
try {
const fresh = await fetchNewToken();
setToken(fresh);
this.ws.send(JSON.stringify({ type: 'TOKEN_REFRESH', token: fresh }));
} catch {
this.ws.close(4004, 'refresh failed'); // fallback vers une reconnexion propre
this.connect();
}
}
}
Le serveur valide le token rafraîchi et met à jour la session attachée à la socket — il ne ferme pas la connexion, ce qui est précisément l’intérêt du chemin in-band :
// Serveur Node.js — ws@8.21.0
ws.on('message', (raw) => {
const msg = JSON.parse(raw);
if (msg.type === 'TOKEN_REFRESH') {
try {
ws.user = verify(msg.token, process.env.JWT_SECRET);
ws.send(JSON.stringify({ type: 'TOKEN_REFRESH_OK' }));
} catch {
ws.close(4003, 'invalid refresh token');
}
}
});
L’autorisation est un contrôle permanent
L’authentification WebSocket n’est pas un événement ponctuel à l’ouverture de la connexion : les connexions étant longue durée, un token valide au moment du handshake peut expirer, être révoqué ou perdre les permissions qui lui sont associées pendant que la socket est toujours ouverte, ce qui fait de l’autorisation par message une exigence de production, et non une mesure de sécurité optionnelle. Les permissions changent en cours de connexion via des actions administratives, l’expiration d’abonnements ou des mesures de modération — et le handshake effectué il y a une heure ne peut tenir compte d’aucun de ces changements.
La solution consiste à vérifier l’autorisation à chaque message entrant, pas seulement à l’établissement de la connexion. Traitez le handshake comme l’établissement de l’identité et chaque message comme une décision d’autorisation indépendante :
// Serveur Node.js — vérification des permissions par message
const PERMISSIONS = { admin: ['read', 'write', 'delete'], user: ['read', 'write'], guest: ['read'] };
ws.on('message', (raw) => {
const msg = JSON.parse(raw);
const allowed = PERMISSIONS[ws.user.role] || [];
if (!allowed.includes(msg.action)) {
return ws.send(JSON.stringify({ type: 'FORBIDDEN', action: msg.action }));
}
handle(msg, ws);
});
Associez cela à une vérification de révocation afin qu’un utilisateur déconnecté ou banni ne puisse pas continuer à opérer sur une socket déjà ouverte. Un petit Set en mémoire d’identifiants de tokens révoqués fonctionne pour un processus unique ; un store partagé comme Redis est nécessaire dès que vous faites tourner plusieurs nœuds WebSocket, car la révocation doit être visible par le nœud qui détient la connexion.
Liste de contrôle de sécurité et débogage des échecs silencieux
Sécuriser une connexion WebSocket se résume à une liste de contrôle courte et concrète. Parcourez-la avant de mettre en production :
- Utilisez uniquement
wss://. La RFC 6455 §10.6 recommande d’exécuter les WebSockets sur TLS pour la confidentialité et l’intégrité. Lews://non chiffré expose les tokens et les messages en transit. - Gardez les tokens de courte durée. La Session Management Cheat Sheet de l’OWASP recommande des délais d’inactivité de 2 à 5 minutes pour les applications à haute valeur et de 15 à 30 minutes pour celles à moindre risque ; privilégiez les durées courtes pour les tokens stockés dans le navigateur.
- Imposez un délai d’auth pour l’auth par premier message (5 à 10 secondes, par convention) et fermez les sockets non authentifiées.
- Limitez le débit des connexions par IP ou par utilisateur pour plafonner le renouvellement de sockets non authentifiées. Tout nombre spécifique (comme un nombre maximum de connexions simultanées) est indicatif, pas normatif — définissez-le à partir de votre propre base de trafic.
- Prenez en charge la révocation. Vérifiez une liste de révocation à la connexion et à chaque message afin qu’une session déconnectée ne puisse pas persister sur une socket ouverte.
- Validez l’
Originpour l’auth par cookie, et définissezSameSite=StrictouLax.
Ces points sont importants car les échecs d’auth WebSocket sont silencieux. Lorsqu’un token de courte durée expire en cours de session et que le serveur ferme la socket, le navigateur déclenche un événement close ; si l’application ne le gère pas, l’interface peut continuer à afficher un état « connecté » pendant que tous les messages suivants sont silencieusement abandonnés — un mode d’échec invisible dans les logs serveur, mais visible dans le rejeu de session, où l’on observe le dernier message réussi suivi d’un silence côté client. Il en va de même pour le pattern par premier message : sur une connexion lente, les messages mis en file d’attente avant la fin de l’auth peuvent être abandonnés si le délai d’auth se déclenche en premier, et le rejeu de session de ces sessions montre le client envoyant des messages qui ne produisent jamais de réponse pendant que la socket semble toujours ouverte. Rejouer le côté client est souvent le seul moyen d’expliquer une classe de bugs que les logs serveur ne montrent que comme une trame de fermeture sans contexte.
Conclusion
Traitez l’absence d’en-tête Authorization comme le point de départ, pas comme le problème dans son ensemble : choisissez une méthode de transmission à partir de la règle de décision ci-dessus, puis définissez votre stratégie de renouvellement et câblez l’autorisation par message avant que la connexion ne soit jamais longue durée en production. Le handshake prouve qui s’est connecté ; tout ce qui suit est là où réside la véritable sécurité WebSocket. Commencez par auditer une socket existante — confirmez qu’elle fonctionne sur wss://, rejette les tokens expirés en cours de session, et signale une connexion fermée à l’utilisateur plutôt que d’abandonner silencieusement les messages.
FAQ
Puis-je envoyer un en-tête Authorization Bearer avec une connexion WebSocket depuis le navigateur ?
Non. Le constructeur WebSocket du navigateur n'accepte qu'une URL et un tableau de sous-protocoles optionnel, il n'existe donc aucune API pour attacher un en-tête Authorization personnalisé au handshake d'ouverture. Les quatre alternatives pratiques sont un token dans la chaîne de requête, un cookie de session envoyé automatiquement par le navigateur, des identifiants encodés dans la valeur de sous-protocole Sec-WebSocket-Protocol, ou des identifiants envoyés comme premier message après l'ouverture de la connexion. Les bibliothèques WebSocket natives hors navigateur, comme les clients Node.js, peuvent définir des en-têtes arbitraires, mais le code navigateur ne le peut pas.
Que se passe-t-il avec les messages envoyés avant la fin de l'authentification par premier message ?
Les messages envoyés avant la fin du handshake AUTH doivent être mis en file d'attente côté client et envoyés uniquement après confirmation de l'authentification par le serveur, car tout ce qui est envoyé avant peut être silencieusement abandonné. Si le délai d'auth du serveur se déclenche avant de recevoir des identifiants valides, généralement dans une fenêtre de 5 à 10 secondes, la connexion se ferme et tous les messages en transit ou en file d'attente sont perdus. Sur les connexions lentes, cela produit un échec où la socket semble toujours ouverte alors que les messages ne reçoivent jamais de réponse. Conditionnez toujours les envois applicatifs à un indicateur d'authentification réussie.
Dois-je rafraîchir un token WebSocket in-band ou fermer et me reconnecter ?
Utilisez le rafraîchissement in-band — envoyer un nouveau token sur la socket existante — lorsque la connexion porte des abonnements avec état ou des opérations en attente qui seraient perdues lors d'une reconnexion. Utilisez la fermeture-reconnexion lorsque la connexion est sans état et que le client peut rétablir le contexte à moindre coût sans perte de données. Le choix est architectural, pas stylistique : la reconnexion détruit l'état d'abonnement côté serveur, donc pour un flux en direct avec de nombreux canaux actifs, le rafraîchissement in-band évite la surcharge de réabonnement et les événements perdus pendant l'interruption.
Pourquoi mon interface WebSocket affiche-t-elle 'connecté' après l'expiration du token ?
Le serveur ferme la socket à l'expiration du token et le navigateur déclenche un événement close, mais si la gestion d'état de l'application ne traite pas cet événement, l'interface continue d'afficher un état connecté pendant que tous les messages suivants sont silencieusement abandonnés. Cet échec est invisible dans les logs serveur, qui n'enregistrent qu'une trame de fermeture sans contexte, mais visible dans le rejeu de session comme le dernier message réussi suivi d'un silence côté client. Gérez explicitement l'événement close pour signaler la déconnexion et déclencher la reconnexion.