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 :
| Cas | Clé choisie | Verdict de jsonwebtoken 9 |
|---|---|---|
alg: HS256 | secret partagé | vérifié |
alg: ES256 / RS256 | clé publique JWKS (par kid) | vérifié |
alg: none ou inconnu | secret (fallback) | rejeté : hors liste algorithms ou signature exigée |
alg: HS256 signé avec la clé publique | secret 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
- Un 401 sur un JWT que Supabase accepte signifie presque toujours une mauvaise clé de vérification. Le guard NestJS masque le message de
jsonwebtoken, qu’il faut journaliser pour voirsecretOrPublicKey must be an asymmetric key. - La présence d’une variable de secret ne dit rien de l’algorithme des tokens reçus. Un ternaire « secret sinon JWKS » casse dès que le secret legacy reste configuré.
- L’URL JWKS de Supabase est
/auth/v1/.well-known/jwks.json, pas/.well-known/jwks.json. Le préfixe du service d’authentification est obligatoire. - Choisir la clé selon
algest acceptable sialgorithmsimpose une liste blanche. C’estjsonwebtokenqui rejettenone, les algorithmes inconnus et les confusions de clé. - Un provider de clé exporté se teste en isolation. Construire un faux JWT avec le seul en-tête suffit à couvrir chaque branche sans réseau ni Passport.