📍 Article principal : Hono framework TypeScript en production 2026
Introduction
Une plateforme B2B de Dakar utilisée par 800 commerciaux a vu, il y a six mois, ses sessions utilisateurs dérivées par un attaquant qui avait récupéré un token JWT dans un log mal configuré. Le token, valable 30 jours, lui donnait accès à toute la base clients. Le post-mortem a identifié trois erreurs cumulées : tokens trop longs en durée, pas de refresh token, secret JWT loggué accidentellement. Ce tutoriel construit l’authentification que cette plateforme aurait dû avoir dès le départ : access tokens courts (15 minutes) qui ne donnent aucun pouvoir au-delà, refresh tokens rotatifs stockés en base, blacklist instantanée des sessions compromises, hash Argon2id des mots de passe, et discipline de logs qui ne fuit jamais le secret. À la fin de ce guide, vous avez une auth Hono prête pour la production, conforme aux recommandations OWASP 2026, et utilisable telle quelle sur un projet client.
Le pattern présenté est le standard de fait pour les SaaS sérieux en 2026 : il évite les attaques classiques (vol de cookie, replay, brute force) sans introduire la complexité d’un OAuth complet. Pour une intégration SSO Google ou Apple, on garde ce pattern et on ajoute un endpoint /api/auth/oauth-callback qui crée la session locale après vérification du provider — extension naturelle, pas réécriture.
Prérequis
- Projet Hono fonctionnel (Node, Bun ou Cloudflare Workers)
- Base PostgreSQL ou D1 avec migrations gérées (Drizzle recommandé)
- Bibliothèques :
hono/jwt,argon2ouoslo/password,nanoid - Niveau : intermédiaire, sensibilité sécurité — Temps : 2 heures
Étape 1 — Modèle de données
Trois tables minimales : users pour les comptes, sessions pour les refresh tokens actifs, audit_log pour la traçabilité des connexions. Le schéma Drizzle simplifié :
// db/schema.ts
import { pgTable, text, timestamp, boolean } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
motDePasseHash: text('mot_de_passe_hash').notNull(),
nom: text('nom').notNull(),
actif: boolean('actif').notNull().default(true),
creeLe: timestamp('cree_le').defaultNow().notNull()
});
export const sessions = pgTable('sessions', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
refreshTokenHash: text('refresh_token_hash').notNull(),
expireLe: timestamp('expire_le').notNull(),
revoke: boolean('revoke').notNull().default(false),
creeLe: timestamp('cree_le').defaultNow().notNull(),
derniereUtilisation: timestamp('derniere_utilisation').defaultNow().notNull(),
ipCreation: text('ip_creation'),
userAgent: text('user_agent')
});
On stocke le hash du refresh token en base, jamais le token en clair. En cas de fuite de la base, l’attaquant ne peut pas usurper de session sans casser le hash. Cette précaution suit le même principe que le hashage des mots de passe.
Étape 2 — Endpoint /api/auth/login
import { Hono } from 'hono';
import { setCookie } from 'hono/cookie';
import { sign } from 'hono/jwt';
import { hash, verify } from '@node-rs/argon2';
import { nanoid } from 'nanoid';
import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';
const auth = new Hono();
const ACCESS_DUREE = 60 * 15; // 15 minutes
const REFRESH_DUREE = 60 * 60 * 24 * 30; // 30 jours
const loginSchema = z.object({
email: z.string().email(),
motDePasse: z.string().min(8)
});
auth.post('/login', zValidator('json', loginSchema), async (c) => {
const { email, motDePasse } = c.req.valid('json');
// Délai constant pour éviter le timing attack
const u = await db.select().from(users).where(eq(users.email, email)).limit(1);
const user = u[0];
const fakeHash = '$argon2id$v=19$m=19456,t=2,p=1$...';
const ok = user
? await verify(user.motDePasseHash, motDePasse)
: await verify(fakeHash, motDePasse).then(() => false);
if (!user || !ok || !user.actif) {
return c.json({ erreur: 'Identifiants invalides' }, 401);
}
const sessionId = nanoid(24);
const refreshToken = nanoid(48);
const refreshHash = await hash(refreshToken);
await db.insert(sessions).values({
id: sessionId, userId: user.id, refreshTokenHash: refreshHash,
expireLe: new Date(Date.now() + REFRESH_DUREE * 1000),
ipCreation: c.req.header('x-forwarded-for'),
userAgent: c.req.header('user-agent')
});
const accessToken = await sign(
{ sub: user.id, sid: sessionId, exp: Math.floor(Date.now()/1000) + ACCESS_DUREE },
c.env.JWT_SECRET
);
setCookie(c, 'rt', refreshToken, {
httpOnly: true, secure: true, sameSite: 'Lax',
path: '/api/auth/refresh', maxAge: REFRESH_DUREE
});
return c.json({ accessToken, user: { id: user.id, email: user.email, nom: user.nom } });
});
Trois mécanismes de défense visibles dans ce code. Le délai constant (vérifier un faux hash si l’utilisateur n’existe pas) évite le timing attack qui révèle l’existence d’un email. Le refresh token est stocké en cookie httpOnly donc invisible au JavaScript client (XSS ne peut pas le voler). Le path du cookie est restreint à /api/auth/refresh donc il n’est envoyé qu’à cet endpoint, jamais en transit ailleurs.
Étape 3 — Endpoint /api/auth/refresh
import { getCookie } from 'hono/cookie';
auth.post('/refresh', async (c) => {
const oldToken = getCookie(c, 'rt');
if (!oldToken) return c.json({ erreur: 'Pas de refresh token' }, 401);
const tousSessions = await db.select().from(sessions).where(eq(sessions.revoke, false));
let session = null;
for (const s of tousSessions) {
if (await verify(s.refreshTokenHash, oldToken)) { session = s; break; }
}
if (!session || session.expireLe < new Date()) {
return c.json({ erreur: 'Refresh invalide' }, 401);
}
// Rotation : révoquer l'ancien et créer un nouveau
const nouveauToken = nanoid(48);
const nouveauHash = await hash(nouveauToken);
await db.update(sessions).set({ revoke: true }).where(eq(sessions.id, session.id));
const nouveauSid = nanoid(24);
await db.insert(sessions).values({
id: nouveauSid, userId: session.userId, refreshTokenHash: nouveauHash,
expireLe: new Date(Date.now() + REFRESH_DUREE * 1000)
});
const accessToken = await sign(
{ sub: session.userId, sid: nouveauSid, exp: Math.floor(Date.now()/1000) + ACCESS_DUREE },
c.env.JWT_SECRET
);
setCookie(c, 'rt', nouveauToken, { httpOnly: true, secure: true, sameSite: 'Lax', path: '/api/auth/refresh', maxAge: REFRESH_DUREE });
return c.json({ accessToken });
});
La rotation systématique du refresh token à chaque utilisation est le mécanisme central. Si un attaquant vole un refresh token et l’utilise, le client légitime, en utilisant à son tour son token, déclenche une erreur 401 et la session est invalidée — les deux camps perdent l’accès, ce qui force la reconnexion et alerte l’utilisateur. Ce pattern, recommandé par l’OWASP depuis 2024, élimine la classe entière des replay attacks sur les tokens persistants.
Étape 4 — Middleware requireAuth
// middleware/requireAuth.ts
import { jwt } from 'hono/jwt';
import { createMiddleware } from 'hono/factory';
export const requireAuth = createMiddleware(async (c, next) => {
const jwtMw = jwt({ secret: c.env.JWT_SECRET });
return jwtMw(c, async () => {
const payload = c.get('jwtPayload');
// Vérifier que la session n'est pas révoquée
const session = await db.select().from(sessions).where(eq(sessions.id, payload.sid)).limit(1);
if (!session[0] || session[0].revoke) {
return c.json({ erreur: 'Session révoquée' }, 401);
}
c.set('userId', payload.sub);
await next();
});
});
// Usage
app.get('/api/clients', requireAuth, async (c) => {
const userId = c.get('userId');
const list = await db.select().from(clients).where(eq(clients.proprietaireId, userId));
return c.json(list);
});
Étape 5 — Logout et révocation
auth.post('/logout', async (c) => {
const oldToken = getCookie(c, 'rt');
if (oldToken) {
const tous = await db.select().from(sessions).where(eq(sessions.revoke, false));
for (const s of tous) {
if (await verify(s.refreshTokenHash, oldToken)) {
await db.update(sessions).set({ revoke: true }).where(eq(sessions.id, s.id));
break;
}
}
}
setCookie(c, 'rt', '', { maxAge: 0, path: '/api/auth/refresh' });
return c.json({ ok: true });
});
auth.post('/logout-all', requireAuth, async (c) => {
const userId = c.get('userId');
await db.update(sessions).set({ revoke: true }).where(eq(sessions.userId, userId));
setCookie(c, 'rt', '', { maxAge: 0, path: '/api/auth/refresh' });
return c.json({ ok: true });
});
Le /logout-all est la fonctionnalité « Se déconnecter de tous les appareils » indispensable. Un utilisateur qui a oublié son téléphone dans un taxi à Yopougon clique sur ce bouton depuis un autre appareil et toutes les sessions sont invalidées en une seconde.
Étape 6 — Couches de sécurité additionnelles
Trois protections à activer en plus du pattern de base. Rate limit sur /login : 5 tentatives par IP toutes les 10 minutes, sinon blocage temporaire. Cela bloque les attaques par force brute. Notification de connexion suspecte : si l’IP ou l’user-agent diffère significativement de ceux des sessions précédentes, envoyer un email automatique à l’utilisateur. Authentification à deux facteurs via TOTP : pour les comptes admin ou sensibles, ajouter un second facteur stocké en base sous forme chiffrée et vérifié à la connexion.
Pour le 2FA, la lib otplib sur Node ou oslo/2fa sur n’importe quel runtime gère la génération et la vérification. Le secret est stocké chiffré (jamais en clair), la clé de chiffrement venant des secrets Cloudflare ou variables d’env. À l’activation, on génère un QR code que l’utilisateur scanne avec Google Authenticator, Authy ou Aegis.
Erreurs fréquentes
| Erreur | Cause | Solution |
|---|---|---|
| Tokens valides éternellement | Pas de champ exp dans le payload JWT |
Toujours signer avec exp court (15 min max) |
| Refresh token volé via XSS | Cookie sans httpOnly |
Ajouter httpOnly: true, secure: true |
| Brute force sur /login non bloqué | Pas de rate limit | Implémenter limiteur par IP (KV ou Redis) |
| Mot de passe faible accepté | Pas de validation Zod stricte | Min 10 caractères, majuscule, chiffre, symbole |
| Logs contiennent le secret JWT | console.log(c.env) |
Auditer tous les logs, masquer les variables sensibles |
| Session reste valide après changement de mot de passe | Pas de révocation des sessions | Appeler logout-all après MAJ mot de passe |
Adaptation au contexte ouest-africain
Trois aspects pratiques. Premièrement, sur les terminaux d’entrée de gamme (Tecno, Itel) avec connectivité variable, le client doit gérer gracieusement les 401 dus à un access token expiré : intercepter, appeler /refresh, rejouer la requête originale. Le SDK officiel SvelteKit ou un fetch wrapper custom encapsulent ce comportement transparent. Deuxièmement, pour les apps utilisées sur des cybercafés (encore courants à Bamako, Niamey), le bouton « Se déconnecter de tous les appareils » est crucial — bien le mettre en évidence dans les paramètres. Troisièmement, la conformité avec la loi sénégalaise sur la protection des données personnelles 2008-12 et les futures évolutions UEMOA-CEDEAO impose : durée de conservation des sessions documentée, droit à l’oubli implémenté (anonymisation à la demande), audit log accessible à l’utilisateur sur demande. Notre pattern de base supporte tout cela sans modification structurelle.
Tutoriels frères
Pour aller plus loin
- 🔝 Pilier : Hono en production 2026
- OWASP : JWT Security Cheat Sheet
- Doc : Hono JWT middleware
FAQ
Pourquoi 15 minutes pour l’access token ?
C’est le compromis standard : assez court pour limiter les dégâts en cas de fuite, assez long pour ne pas multiplier les appels à /refresh. Pour des comptes très sensibles, descendre à 5 minutes. Pour des intégrations machine-to-machine, on peut monter à 1 heure.
Et si JWT est mal vu en 2026 ?
JWT a fait l’objet de critiques justifiées (révocation difficile, secrets statiques). Notre pattern résout ces problèmes via la table sessions qui réintroduit la révocation côté serveur. Une alternative est d’utiliser des sessions opaques (UUID stocké en base) sans JWT — plus simple à révoquer, mais nécessite un appel base à chaque requête.
Comment migrer depuis NextAuth ou Lucia ?
NextAuth utilise principalement des sessions en base (similaire à notre pattern). La migration consiste à exporter la table users et à reconstruire la couche d’auth Hono. Lucia (archivée en 2024) suit déjà ce pattern.
Faut-il refresh token côté mobile natif ?
Oui. Sur mobile, on stocke le refresh token dans le keychain iOS ou EncryptedSharedPreferences Android, jamais dans AsyncStorage en clair. La rotation et la révocation fonctionnent identiquement.
Patterns avancés : passwordless et magic links
Pour les SaaS B2B où la friction d’inscription doit être minimale, le pattern passwordless via magic link remplace avantageusement l’authentification mot de passe. L’utilisateur saisit son email, reçoit un lien à usage unique, clique, et est connecté. Aucun mot de passe à mémoriser, aucun risque de réutilisation, aucun support utilisateur lié aux mots de passe oubliés. Implémentation sous Hono : un endpoint /api/auth/magic-link qui génère un token signé court (15 minutes), stocke son hash en base, envoie l’email via Resend ou Mailgun. Un endpoint /api/auth/magic-link/verify vérifie le token, le marque comme consommé, et crée la session normale.
Le pattern fonctionne particulièrement bien pour les utilisateurs ouest-africains qui changent souvent de smartphone — pas besoin de migrer un gestionnaire de mots de passe, l’email suffit. Pour les SaaS qui s’adressent à des entreprises avec plusieurs employés (back-office d’un transporteur logistique à Bamako, par exemple), on combine magic link et 2FA TOTP : la première connexion sur un nouvel appareil exige le code TOTP, puis le device est mémorisé pour 30 jours.
Audit trail et conformité
Une table audit_log alimentée à chaque événement sensible (login, logout, changement mot de passe, accès admin, export de données) permet de répondre aux exigences de traçabilité de la loi sénégalaise et des règlements UEMOA en cours d’élaboration. Schéma minimal : timestamp, user_id, action, entity_type, entity_id, ip, user_agent, success/fail. Avec ~10 colonnes, on couvre 95 % des besoins forensiques en cas d’incident de sécurité.
Les logs sont rétents 12 mois minimum, accessibles uniquement aux admins, et exportables au format CSV pour audit externe. Un cron mensuel archive les logs de plus d’un an vers R2 ou Hetzner Storage Box, libérant la base PostgreSQL principale. Pour les SaaS qui visent une certification ISO 27001 ou SOC 2, ce pattern d’audit trail est non-négociable et facilite considérablement le passage de l’audit.
Tester l’authentification de bout en bout
Trois niveaux de tests à mettre en place. Tests unitaires sur les helpers (hash, signing, validation) qui s’exécutent en moins d’une seconde et tournent à chaque commit. Tests d’intégration qui simulent un parcours login → access protégé → refresh → logout en moins de cinq secondes. Tests E2E Playwright qui valident le parcours complet incluant l’UI : saisie du formulaire de login, redirection après succès, protection des routes nécessitant l’auth, déconnexion. Sans cette pyramide, des régressions silencieuses peuvent passer en production — un middleware mal configuré qui laisse passer une requête non authentifiée est invisible jusqu’à ce qu’un attaquant le découvre.