Verifier un JWT HS256 sans dependance, en 30 lignes
Pourquoi nous avons retire jose de notre stack Next.js, et le code Web Crypto qui l'a remplace sans rien casser depuis.
- Auth
- Engineering
Vérifier un JWT n’est pas un problème intéressant. Mais vivre avec une dépendance qui casse à chaque mise à jour Next.js, c’est un problème de tous les jours.
Sur l’une de nos plateformes, deux apps Next.js doivent vérifier des JWT signés par notre API NestJS : un portail client et une console admin. Le secret est partagé via une variable d’environnement, l’algorithme est HS256. Pas de rotation de clés, pas de JWKS, pas de RSA. Juste un cookie à lire, un secret connu, un payload à valider.
Le problème avec jose
Premier réflexe : npm install jose. Trois lignes pour vérifier, terminé.
Sauf que Next.js 16 + Turbopack n’aime pas jose. La librairie est ESM-only, son entry pointe vers dist/webapi/, et selon le runtime résolu (Edge ou Node) le bundler choisit la mauvaise variante. Une journée à voir des erreurs Module not found: jose/dist/webapi/index.js, à rm -rf .next, redémarrer, retomber sur la même erreur.
transpilePackages: ['jose'] aide en webpack, mais Turbopack en dev ignore cette option. jose@v4 (CJS) marche en webpack mais casse l’Edge runtime. On a tourné en rond.
À ce stade, deux options : continuer à se battre avec une dep, ou regarder ce que vérifier un JWT HS256 implique vraiment.
Ce dont on a besoin
Un JWT HS256, c’est trois parties séparées par des points : header, payload, signature. La signature est HMAC-SHA256(secret, header + "." + payload) encodée en base64url. Vérifier, c’est :
- splitter sur les points
- recalculer la HMAC sur
header.payload - comparer avec la signature reçue
- décoder le payload en JSON
- vérifier
expet la forme attendue
Aucune de ces opérations n’a besoin d’une lib. La Web Crypto API (crypto.subtle) est native dans Node 18+, dans le navigateur, dans Edge runtime, dans Bun, dans Deno.
Le code
function base64urlToBytes(str: string): Uint8Array {
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(
base64.length + ((4 - (base64.length % 4)) % 4),
"="
);
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
}
export async function verifyJwt(token: string): Promise<Payload | null> {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [headerB64, payloadB64, signatureB64] = parts;
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(process.env.JWT_SECRET!),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"]
);
const valid = await crypto.subtle.verify(
"HMAC",
key,
base64urlToBytes(signatureB64).buffer as ArrayBuffer,
new TextEncoder().encode(`${headerB64}.${payloadB64}`)
);
if (!valid) return null;
const payload = JSON.parse(
new TextDecoder().decode(base64urlToBytes(payloadB64))
);
if (payload.exp && Date.now() / 1000 > payload.exp) return null;
return payload;
} catch {
return null;
}
}
Trente lignes. Pas de dépendance, pas de dist/webapi/, pas d’ESM/CJS qui se croisent. Marche en Node 18+, en Edge runtime, dans le navigateur — partout où il y a Web Crypto.
Les détails qui comptent
base64urlToBytes : atob ne sait pas lire base64url (avec - et _ au lieu de + et /). On remappe et on padde. Cinq lignes, jamais plus à toucher.
.buffer as ArrayBuffer : TypeScript récent voit Uint8Array<ArrayBufferLike> au lieu de BufferSource. Le cast est moche mais documenté.
Validation de la forme : on décode le JSON et on vérifie la forme du payload (champs requis, types attendus). Un JWT mal-formé n’est jamais signé par notre secret, donc la signature aurait fail — mais si quelqu’un signe avec notre secret un payload mal-formé, on doit refuser. Trois typeof à ajouter, pas plus.
Pas de signature ici : ce code vérifie. Pour signer, on est côté NestJS qui utilise @nestjs/jwt. La symétrie n’est pas un problème : signer arrive une fois (au login), vérifier arrive à chaque requête. La complexité de @nestjs/jwt est justifiée pour signer (claims standards, refresh, rotation), pas pour vérifier.
Le piège
Le secret est lu depuis process.env.JWT_SECRET. Si on met un fallback par défaut “pour le confort dev”, la variable manquante en prod passe inaperçue. On a ajouté un check au démarrage de chaque app dans le layout serveur : si process.env.NODE_ENV === 'production' et que la variable manque, on throw. Trente secondes à écrire, six mois à éviter le bug.
Avec 6 mois de recul
Le code n’a pas bougé. Pas une upgrade Next.js qui l’a cassé. Pas un hotfix Turbopack. Pas un changement de runtime.
La seule chose qu’on changerait : extraire base64urlToBytes dans un fichier partagé, parce qu’on l’utilise aussi côté tests. Cinq minutes.
La leçon n’est pas “ne jamais utiliser de lib JWT”. C’est : avant d’installer une dep, regarder ce qu’elle remplace côté plateforme. Si l’API native du runtime fait le travail, la lib est un coût net.