Aller au contenu
Sylvain Joffraud l4w
Retour

JWT Supabase en ES256 : 401 avec Passport NestJS

Un JWT Supabase signé en ES256 donnait un 401 sur toutes les routes authentifiées de mon API NestJS : GET /api/events (publique) répondait, GET /api/tickets/me renvoyait 401 Unauthorized, alors que Supabase validait lui-même le même token. La cause : la stratégie Passport préférait toujours le secret HS256 legacy dès que SUPABASE_JWT_SECRET était défini, et ne consultait jamais les clés JWKS, alors que les projets Supabase récents signent en ES256. Contexte : le backend d’une billetterie événementielle, NestJS 10.4, passport-jwt 4.0.1, jwks-rsa 3.2.2, jsonwebtoken 9.0.3.

HS256 legacy, ES256 et JWKS : ce que signe Supabase

Supabase a longtemps signé tous les JWT d’un projet avec un secret partagé : l’algorithme HS256 (HMAC-SHA256), où la même clé signe et vérifie. Les projets récents utilisent les JWT signing keys : une paire de clés asymétriques, et l’algorithme ES256 (ECDSA sur courbe P-256), où seule la clé publique sert à vérifier.

JWKS (JSON Web Key Set) est le document JSON qui publie ces clés publiques, chacune identifiée par un kid. Chez Supabase, il se trouve sur :

https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json

Le préfixe /auth/v1 compte : ma première version pointait sur /.well-known/jwks.json à la racine du projet, et il a fallu un second correctif pour la bonne URL.

Pourquoi le JWT ES256 donne 401 dans NestJS ?

La première tentative de support ES256 ressemblait à ça :

const secretOrKeyProvider = jwtSecret
  ? jwtSecret                       // secret défini → HS256, point final
  : passportJwtSecret({ jwksUri }); // JWKS seulement si pas de secret

Le ternaire a l’air raisonnable, mais la variable SUPABASE_JWT_SECRET restait définie en environnement (le secret legacy existe toujours dans le projet). La branche JWKS n’était donc jamais atteinte. Chaque token ES256 arrivait devant jsonwebtoken avec une clé symétrique, un cas que la bibliothèque (v9) rejette avec ce message :

secretOrPublicKey must be an asymmetric key when using ES256

Je ne l’ai jamais vu passer, et pour cause : il ne sort jamais de l’API, le AuthGuard de NestJS le transforme en {"statusCode":401,"message":"Unauthorized"}. D’où la fausse piste classique, chercher du côté du token (expiré ? mauvaise audience ?) alors que le token était parfait. Le bug est apparu lors d’une campagne de tests E2E, avec un token dont l’en-tête portait alg: ES256 et un kid.

Un 401 sur un JWT que le fournisseur valide lui-même ne signale pas un mauvais token : l’API vérifie avec la mauvaise clé.

Le correctif : un secretOrKeyProvider qui lit alg

secretOrKeyProvider est l’option de passport-jwt qui fournit la clé token par token, via un callback. Le provider décode l’en-tête et choisit la clé selon alg :

export function buildSecretOrKeyProvider(jwtSecret?: string, supabaseUrl?: string) {
  const jwks = supabaseUrl
    ? passportJwtSecret({
        cache: true,
        rateLimit: true,
        jwksRequestsPerMinute: 5,
        jwksUri: `${supabaseUrl}/auth/v1/.well-known/jwks.json`,
      })
    : null;

  return (req, rawJwt: string, done) => {
    let alg: string | undefined;
    try {
      alg = JSON.parse(Buffer.from(rawJwt.split('.')[0], 'base64').toString()).alg;
    } catch {
      return done(new Error('Invalid JWT header'));
    }
    if (alg === 'HS256' && jwtSecret) return done(null, jwtSecret);
    if ((alg === 'ES256' || alg === 'RS256') && jwks) return jwks(req, rawJwt, done);
    if (jwtSecret) return done(null, jwtSecret);
    if (jwks) return jwks(req, rawJwt, done);
    return done(new Error('No key provider configured'));
  };
}

La stratégie passe ce provider à super() avec algorithms: ['HS256', 'ES256', 'RS256']. Exporter la fonction permet de la tester sans Passport : cinq tests Jest couvrent un en-tête HS256 (le secret est renvoyé), un en-tête ES256 (le secret n’est pas renvoyé, JWKS prend la main), un en-tête malformé, un alg inconnu et l’absence de toute configuration.

Faire confiance à alg, sans lui faire confiance

Lire alg dans un token non vérifié rappelle les attaques par confusion d’algorithme. Le choix de la clé reste sûr parce que la décision finale appartient à jsonwebtoken, pas au provider :

CasClé choisieVerdict de jsonwebtoken 9
alg: HS256secret partagévérifié
alg: ES256 / RS256clé publique JWKS (par kid)vérifié
alg: none ou inconnusecret (fallback)rejeté : hors liste algorithms ou signature exigée
alg: HS256 signé avec la clé publiquesecret partagérejeté : signature HMAC invalide

La liste blanche algorithms est donc la vraie barrière ; le fallback sur le secret pour un alg inconnu ne sert qu’à produire une erreur de vérification plutôt qu’un plantage. Côté JWKS, cache: true garde les clés en mémoire et rateLimit plafonne les appels à 5 requêtes par minute : un flot de tokens avec des kid fantaisistes ne transforme pas l’API en proxy de déni de service vers Supabase.

Lessons learned

Questions fréquentes

Pourquoi mon API NestJS renvoie 401 avec un JWT Supabase pourtant valide ?
Les projets Supabase récents signent leurs JWT en ES256 avec une clé asymétrique, alors que beaucoup de stratégies Passport vérifient encore avec le secret HS256 legacy SUPABASE_JWT_SECRET. La bibliothèque jsonwebtoken refuse alors le token avec secretOrPublicKey must be an asymmetric key when using ES256, et le guard NestJS le traduit en simple 401 Unauthorized.
Quelle est l’URL JWKS d’un projet Supabase ?
Les clés publiques de signature d’un projet Supabase sont exposées sur https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json. Le préfixe /auth/v1 est indispensable : l’URL /.well-known/jwks.json à la racine du projet ne correspond pas à l’endpoint du service d’authentification, et la récupération des clés échoue.
Comment accepter à la fois les JWT Supabase HS256 et ES256 avec passport-jwt ?
Avec passport-jwt, l’option secretOrKeyProvider accepte une fonction appelée pour chaque token. Cette fonction lit le champ alg de l’en-tête : pour HS256, elle renvoie le secret partagé ; pour ES256 ou RS256, elle délègue à passportJwtSecret de jwks-rsa, qui récupère la clé publique par son kid.
Lire le champ alg du JWT avant vérification est-il dangereux ?
Lire alg pour choisir la clé n’est pas dangereux si la vérification impose une liste blanche via l’option algorithms de passport-jwt. Un alg absent de la liste, none compris, est rejeté par jsonwebtoken. Depuis la version 9, jsonwebtoken refuse aussi une clé publique utilisée comme secret HMAC, ce qui bloque la confusion d’algorithmes.

Partager cet article :

Article précédent
Deux VPN sur le même /24 : le full-tunnel éclipse ton mesh
Article suivant
supabase-js : « native WebSocket not found », Node 22 requis