My App

ADR-02 — Auth via cookies HttpOnly uniquement

Pas de Bearer token côté client, cookies signés avec proxy Next rewrites en dev

Statut : Accepté Date : 2026-04 Sujet : Mécanisme d'authentification côté client web

Contexte

Dans l'ancien projet Bell, on mélangeait Bearer token (stocké en SecureStore/localStorage) et cookies HttpOnly (Better Auth par défaut). Ce mix a causé des heures de debug :

  • PWA sur localhost:8081 + API sur 192.168.1.128:3000 → cookies cross-origin rejetés par les browsers
  • credentials: "include" + SameSite=Lax → inconsistant selon les navigateurs
  • Flag bell_session_active en localStorage pour compenser les races d'hydration → source de boucles //auth/waiting
  • authClient.getSession() parfois synchrone, parfois async → race conditions après signUp.email

Résultat : un guard client fragile, des 401 aléatoires, des logouts silencieux après refresh.

Alternatives considérées

Option 1 — Bearer token only (localStorage)

Comme dans beaucoup de SPA tRPC/REST. Token lu au démarrage, envoyé en Authorization: Bearer.

Pour :

  • Fonctionne cross-origin sans config CORS complexe
  • Pas de dépendance aux cookies

Contre :

  • Vulnérable XSS — un script injecté lit localStorage
  • Pas de rotation automatique (il faut un refresh token manuel)
  • Pas d'expiration côté serveur sans logique custom
  • Mauvaise UX sur mobile PWA (localStorage parfois wipé)

Option 2 — Mix Bearer + cookies (ancien projet)

C'est ce qu'on faisait. Bearer pour cross-origin dev, cookies en prod.

Contre :

  • Double source de vérité, deux chemins de bug
  • Session peut désynchroniser (flag localStorage pense "connecté", cookie expiré côté serveur)
  • Bugs déjà documentés dans l'ancien projet

Option 3 — Cookies HttpOnly uniquement (retenu)

Cookies signés par Better Auth, HttpOnly + Secure + SameSite=Lax. Pas de token côté JS.

Pour :

  • Non accessible au JS → résiste XSS
  • Rotation automatique par Better Auth
  • Expiration côté serveur propre
  • Standard web, pas de magie

Contre :

  • Cross-origin nécessite config précise (credentials + CORS)
  • En dev, si API et front sont sur des origins différents, les browsers bloquent les cookies third-party

Solution au contre : en dev, on utilise les Next.js rewrites pour faire apparaître l'API comme same-origin du front. En prod, même-domaine parent (.hoaiy.com) → cookies partagés entre bell-app.hoaiy.com et bell-staff.hoaiy.com.

Décision

Serveur Elysia / Better Auth

// packages/auth/src/index.ts
export const auth = betterAuth({
  // ...
  advanced: {
    defaultCookieAttributes: {
      sameSite: "lax",
      secure: process.env.NODE_ENV === "production",
      httpOnly: true,
      domain: baseDomain,          // ".hoaiy.com" en prod, undefined en dev
    },
  },
  plugins: [
    organization({ /* ... */ }),
    magicLink({ /* ... */ }),
    // PAS DE bearer() — volontaire
  ],
});

Clients (pwa et dashboard)

Dans apps/pwa/next.config.ts et apps/dashboard/next.config.ts :

export default {
  async rewrites() {
    return [
      { source: "/api/auth/:path*",  destination: `${SERVER_URL}/api/auth/:path*` },
      { source: "/api/eden/:path*",  destination: `${SERVER_URL}/:path*` },
    ];
  },
};

Côté client Eden Treaty :

// apps/pwa/src/lib/eden.ts
import { treaty } from "@elysiajs/eden";
import type { App } from "@bell/api";

export const eden = treaty<App>(
  typeof window === "undefined" ? process.env.SERVER_URL! : "/api/eden",
  { fetch: { credentials: "include" } },
);

Résultat : le navigateur voit toutes les requêtes sur le même origin que le front. Le cookie de session better-auth.session_token est transmis automatiquement. Pas de Bearer, pas de localStorage, pas de flag.

Middleware Next.js (server-side guard)

Le guard n'est pas côté client (fini la race d'hydration). C'est un middleware serveur :

// apps/pwa/src/middleware.ts
export async function middleware(req: NextRequest) {
  const sessionToken = req.cookies.get("better-auth.session_token");
  if (!sessionToken) return NextResponse.redirect("/auth/waiting");
  // ... fetch session via API, check role, redirect selon checkInStatus
}

Le client ne gère plus la redirection — c'est le serveur qui décide au moment du render.

Conséquences

Positives :

  • Auth résistante XSS
  • Plus de race d'hydration côté client, plus de flag localStorage
  • Standard web, compréhensible par tout dev
  • Middleware serveur = redirect avant le render = pas de flash d'UI

Négatives :

  • Nécessite proxy Next.js en dev (config ajoutée aux deux apps)
  • En prod, bell-app.hoaiy.com et bell-staff.hoaiy.com doivent partager .hoaiy.com comme domaine cookie — contrainte DNS
  • Middleware serveur coûte quelques ms par request (négligeable, et amorti par edge runtime)

Métriques à surveiller

  • Taux de 401 Unauthorized côté API (cible : < 0.1 %, surtout pas de spikes après deploy)
  • Temps de vie médian d'une session guest (cible : > 24h sans re-login)
  • Temps de vie médian d'une session staff (cible : > 7 jours avec Remember Me)
  • Nombre de signalements "je suis constamment déconnecté" → 0

On this page