Protéger /admin avec Astro Middleware et cookies signés (JWT minimal)
Astro Middleware et cookies signés (JWT minimal) pour sécuriser /admin: formulaire /login, cookie HttpOnly, filtre routes, sans dépendances lourdes. Tutoriel.
Sommaire
Protéger /admin avec Astro Middleware et cookies signés (JWT minimal)
Sécuriser un sous-dossier d’administration dans Astro ne nécessite ni framework d’auth ni base de données. Avec un cookie HttpOnly signé via jose (HS256), un middleware Astro et quelques pages SSR, on obtient une authentification de session simple, robuste et déployable partout.
Objectif
L’objectif est de mettre en place une authentification de session côté serveur sans dépendances lourdes. Concrètement, on ajoute une page /login avec un formulaire, on signe un cookie HttpOnly au moment du POST vers /api/login, on monte un middleware Astro qui filtre l’accès à /admin en vérifiant le cookie, et on s’assure que les pages concernées ne sont pas pré-rendues (prerender = false) pour que la logique d’auth se fasse bien au runtime. Par exemple, un utilisateur anonyme qui va sur /admin est automatiquement redirigé vers /login, saisit le mot de passe, puis est renvoyé vers la page d’admin avec un cookie de session valide.
Objectif et périmètre
Le but est de protéger l’intégralité du sous-arbre /admin grâce à un cookie de session signé (JWT minimal) et un middleware Astro. On construit quatre briques:
- Une page /login avec un formulaire accessible, qui gère aussi un paramètre redirect pour renvoyer l’utilisateur où il voulait aller.
- Un endpoint POST /api/login qui vérifie le mot de passe côté serveur et pose un cookie HttpOnly signé.
- Un endpoint GET /api/logout qui supprime proprement le cookie et renvoie vers /login.
- Un fichier src/middleware.ts qui laisse passer les assets et chemins publics, mais exige un cookie valide sur tout /admin/**.
Pour rester minimal, on n’utilise pas de framework d’authentification externe. On s’appuie seulement sur jose pour signer et vérifier les jetons (algorithme HS256) et on garde l’ensemble autour de ~60 lignes utiles hors markup. Les pages d’admin sont servies en SSR (pas de prerender), de sorte que le middleware a toujours la main sur l’accès.
Préparer le projet
Assure-toi d’utiliser Astro 4.x (ou plus récent) avec Node 18+ pour bénéficier de WebCrypto (requis par jose). Si ton projet n’est pas encore initialisé, crée-le avec l’assistant Astro, puis bascule en SSR via un adapter. Par exemple, pour un déploiement Node:
# Ajoute l'adapter Node pour activer le SSR
npx astro add node
# Installe la bibliothèque de signature JWT
npm i jose
Crée un fichier .env (non commité) avec un secret long (au moins 32 caractères) et un mot de passe d’administration. Le nom du cookie et sa durée de vie sont aussi paramétrables:
# .env
AUTH_SECRET='change-moi-32+chars-avec-du-hasard'
ADMIN_PASSWORD='v3ry_str0ng'
AUTH_COOKIE_NAME='session'
AUTH_COOKIE_TTL_DAYS='7'
En local, Astro charge automatiquement ces variables. En production, déclare-les dans l’environnement de la plateforme (Vercel, Cloudflare, serveur Node, etc.). Un secret modifié invalide toutes les sessions existantes, ce qui est normal et souvent souhaitable lors d’une rotation.
Lib d’auth: signer et vérifier les jetons
On regroupe la logique JWT dans un module dédié. L’idée: signer un payload minimal (ex: sub: 'admin'), définir iat/exp, puis vérifier le token en middleware. Le secret est lu depuis .env et n’est jamais exposé au client.
Fichier: src/lib/auth.ts
// src/lib/auth.ts
import { SignJWT, jwtVerify } from 'jose';
const secret = new TextEncoder().encode(import.meta.env.AUTH_SECRET);
const alg = 'HS256';
const ttlDays = Number(import.meta.env.AUTH_COOKIE_TTL_DAYS ?? 7);
export async function signSession(payload: Record<string, unknown>) {
return await new SignJWT(payload)
.setProtectedHeader({ alg })
.setIssuedAt()
.setExpirationTime(`${ttlDays}d`)
.sign(secret);
}
export async function verifySession(token: string) {
const { payload } = await jwtVerify(token, secret, { algorithms: [alg] });
return payload as Record<string, unknown>;
}
Ce choix HS256 évite l’infrastructure à clé publique/privée et reste suffisant pour un back-office simple. En cas de changement de secret (rotation), tous les jetons précédemment émis deviennent invalides, ce qui force une reconnexion globale.
Page /login (SSR, simple et accessible)
La page /login doit être servie côté serveur (prerender désactivé), car elle interagit avec les cookies et doit traiter la redirection après authentification. On affiche un message d’erreur si la tentative précédente a échoué et on propage un paramètre redirect pour revenir sur la destination souhaitée après succès.
Fichier: src/pages/login.astro
---
// src/pages/login.astro
export const prerender = false;
const err = Astro.url.searchParams.get('e');
const redirectTo = Astro.url.searchParams.get('redirect') ?? '/admin';
---
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Login</title>
</head>
<body>
<main>
<h1>Connexion</h1>
{err && <p style="color:red">Identifiants invalides</p>}
<form method="post" action="/api/login">
<input type="hidden" name="redirect" value={redirectTo} />
<label>
Mot de passe
<input
name="password"
type="password"
autocomplete="current-password"
required
/>
</label>
<button type="submit">Se connecter</button>
</form>
</main>
</body>
</html>
Exemple d’usage: un utilisateur visite /admin/articles. Il est redirigé vers /login?redirect=/admin/articles. Après saisie du mot de passe, il est renvoyé exactement là où il voulait aller.
Endpoint POST /api/login: valider et poser le cookie
Le POST /api/login vérifie le mot de passe côté serveur, signe un JWT minimal et écrit un cookie HttpOnly. Ce cookie n’est pas lisible par le JavaScript du navigateur, limitant les risques XSS. En production, on active le flag Secure (HTTPS requis) et on utilise SameSite=Lax par défaut.
Fichier: src/pages/api/login.ts
// src/pages/api/login.ts
import type { APIContext } from 'astro';
import { signSession } from '../../lib/auth';
export const prerender = false;
export async function POST({ request, cookies, redirect }: APIContext) {
const form = await request.formData();
const password = String(form.get('password') ?? '');
const redirectTo = String(form.get('redirect') ?? '/admin');
if (password !== import.meta.env.ADMIN_PASSWORD) {
return redirect(`/login?e=1&redirect=${encodeURIComponent(redirectTo)}`);
}
const token = await signSession({ sub: 'admin' });
const name = import.meta.env.AUTH_COOKIE_NAME ?? 'session';
cookies.set(name, token, {
httpOnly: true,
secure: import.meta.env.MODE === 'production',
sameSite: 'lax',
path: '/',
maxAge: Number(import.meta.env.AUTH_COOKIE_TTL_DAYS ?? 7) * 24 * 60 * 60
});
return redirect(redirectTo, 303);
}
L’utilisation d’un 303 garantit un retour propre sur une URL GET après la soumission. En local (HTTP), secure reste false; en prod (HTTPS), secure passe à true automatiquement via import.meta.env.MODE.
Middleware de protection: filtrer /admin et dériver vers /login
Le middleware intercepte toutes les requêtes. On laisse passer les routes publiques (login, endpoints d’auth, assets, images optimisées) puis on vérifie la présence d’un cookie valide pour toute URL commençant par /admin. En cas d’absence ou d’invalidité, on redirige vers /login en conservant la destination.
Fichier: src/middleware.ts
// src/middleware.ts
import type { MiddlewareHandler } from 'astro';
import { verifySession } from './lib/auth';
const PROTECTED_PREFIX = '/admin';
const COOKIE = import.meta.env.AUTH_COOKIE_NAME ?? 'session';
export const onRequest: MiddlewareHandler = async (context, next) => {
const { url, cookies } = context;
const { pathname } = url;
// Laisse passer les pages publiques et les assets nécessaires
if (
pathname.startsWith('/login') ||
pathname.startsWith('/api/login') ||
pathname.startsWith('/api/logout') ||
pathname.startsWith('/_image') ||
pathname.startsWith('/assets') ||
pathname.match(/\.(ico|png|jpg|svg|css|js|map)$/)
) {
return next();
}
// Si l'URL n'est pas sous /admin, on continue
if (!pathname.startsWith(PROTECTED_PREFIX)) return next();
// Protection /admin/**
const token = cookies.get(COOKIE)?.value;
if (!token) return redirectToLogin(url);
try {
const payload = await verifySession(token);
// Expose éventuellement l'utilisateur aux pages SSR
context.locals.user = payload;
return next();
} catch {
return redirectToLogin(url);
}
};
function redirectToLogin(url: URL) {
const login = new URL('/login', url);
login.searchParams.set('redirect', url.pathname + url.search);
return new Response(null, {
status: 302,
headers: { Location: login.toString() }
});
}
Astuce utile: si tu souhaites typer Astro.locals.user, tu peux étendre l’interface Locals dans src/env.d.ts. Pour un MVP, l’assignation simple suffit.
Page /admin et endpoint de déconnexion
La page d’admin est servie en SSR et peut lire l’utilisateur depuis Astro.locals si le middleware l’a fixé. On propose aussi un lien de déconnexion qui supprime le cookie côté serveur.
Fichier: src/pages/admin/index.astro
---
// src/pages/admin/index.astro
export const prerender = false;
const user = Astro.locals.user;
---
<html lang="fr">
<body>
<main>
<h1>Admin</h1>
<p>Connecté: {user?.sub ?? 'admin'}</p>
<a href="/api/logout">Se déconnecter</a>
</main>
</body>
</html>
Endpoint de déconnexion: src/pages/api/logout.ts
// src/pages/api/logout.ts
import type { APIContext } from 'astro';
export const prerender = false;
export async function GET({ cookies, redirect }: APIContext) {
const name = import.meta.env.AUTH_COOKIE_NAME ?? 'session';
cookies.delete(name, { path: '/' });
return redirect('/login');
}
Exemple d’enchaînement: un admin se connecte, navigue sur plusieurs sous-pages de /admin, puis clique sur “Se déconnecter”. Le cookie est supprimé et l’accès à /admin est à nouveau protégé.
Sécurité et production: réglages essentiels
En production, serre la vis sur quelques points sans complexifier l’architecture. D’abord, force l’usage d’HTTPS afin de pouvoir marquer le cookie Secure, ce qui interdit sa transmission sur HTTP clair. Ensuite, conserve SameSite='lax' pour réduire l’exposition CSRF par défaut; si ton flux ne requiert jamais d’ouverture cross-site, tente SameSite='strict'. Toutes les actions sensibles doivent rester en POST et vérifier l’origine si tu exposes des endpoints cross-origin; l’ajout d’un token CSRF côté serveur est une extension naturelle si nécessaire.
Anticipe la rotation de secret: tu peux, par exemple, accepter l’ancien secret pendant une courte fenêtre si tu veux éviter une déconnexion globale (non implémenté ici pour rester concis). Pour limiter le bruteforce, applique un rate limiting sur /api/login via ton adapter (middleware Node) ou via l’infrastructure (reverse proxy, CDN, Edge). Veille à ne jamais révéler si l’utilisateur ou le mot de passe est incorrect; dans ce tutoriel, on utilise un mot de passe unique côté serveur, ce qui simplifie la réponse d’erreur.
Enfin, vérifie régulièrement la liste de chemins laissés libres par le middleware. Oublier /_image ou un dossier d’assets peut casser /login; à l’inverse, ajouter par erreur un préfixe trop large pourrait créer une faille.
Test rapide
Lance le serveur de dev avec npm run dev et visite /admin: tu dois être redirigé automatiquement vers /login. Saisis le mot de passe défini dans .env; si la valeur est correcte, tu es renvoyé vers /admin et tu vois un cookie HttpOnly côté navigateur (onglet “Storage” ou “Application” des devtools). Le cookie est invisible depuis document.cookie, ce qui est normal.
Teste ensuite la déconnexion via /api/logout: tu dois revenir à /login et une nouvelle tentative sur /admin doit à nouveau rediriger vers /login. En production avec l’adapter-node, exécute un npm run build puis npm run start. Vérifie que le cookie possède bien le flag Secure (HTTPS) et que les routes sous /admin restent inaccessibles sans session.
Variantes et extensions
Pour gérer des multi-utilisateurs, signe un payload contenant sub: email et, si besoin, des métadonnées (ex: name). Le middleware peut ensuite peupler Astro.locals.user avec ces informations et tes pages SSR peuvent afficher l’email ou filtrer le contenu. Pour introduire des rôles, ajoute un claim role: 'editor' | 'admin' et vérifie-le dans le middleware; par exemple, refuse /admin/users aux non-admins.
Si tu déploies en edge (Vercel Edge, Cloudflare), jose fonctionne grâce à WebCrypto. Valide néanmoins la disponibilité des APIs globales et évite les modules Node non supportés. Pour gérer un timeout d’inactivité, tu peux renouveler le JWT à chaque requête serveur ou stocker un timestamp lastActivity dans un second cookie HttpOnly contrôlé par le serveur.
Enfin, pour remplacer le mot de passe unique, intègre un provider OAuth (GitHub, Google). Après le callback côté serveur, crée le cookie signé exactement comme ci-dessus et redirige l’utilisateur vers /admin. La brique middleware ne change pas.
Checklist
Avant de pousser en prod, prends une minute pour relire et tester. Assure-toi que toutes les pages sensibles définissent export const prerender = false; que les endpoints /api/login et /api/logout se comportent comme attendu (303 après POST, suppression du cookie à la déconnexion); que le middleware redirige tout /admin/** sans session et laisse passer /login, /api/login, /api/logout et les assets. En local, observe que le cookie n’a pas le flag Secure; en prod, vérifie qu’il apparaît avec Secure et SameSite au niveau souhaité. Lance quelques essais de mot de passe erroné pour confirmer le comportement d’erreur sans fuite d’information.
Ressources
- Astro – Middleware et API routes: https://docs.astro.build/fr/guides/middleware/
- Astro – Environnements et variables d’environnement: https://docs.astro.build/fr/guides/environment-variables/
- JOSE (jose) – SignJWT et jwtVerify: https://github.com/panva/jose
- Cookies HttpOnly/SameSite (MDN): https://developer.mozilla.org/fr/docs/Web/HTTP/Cookies
Conclusion
Avec un middleware Astro, un cookie HttpOnly signé via jose et quelques endpoints SSR, on obtient une protection efficace de /admin sans framework d’auth dédié. La solution reste compacte, facile à déployer et suffisamment flexible pour évoluer vers des rôles, une intégration OAuth ou une exécution edge. Commence simple, teste le flux complet, puis durcis progressivement les réglages de sécurité au rythme des besoins.