ITSkillsCenter
Blog

Authentification avec better-auth dans TanStack Start : sessions et OAuth pas-à-pas en 2026

12 min de lecture

Mettre en place une authentification correcte est rarement la partie amusante d’un projet, et pourtant c’est souvent celle qui détermine si on livre dans les temps. better-auth est devenue en 2026 la bibliothèque de référence dans l’écosystème TypeScript moderne : framework-agnostic, sessions sécurisées par défaut, OAuth multi-provider, magic links, 2FA, multi-tenant. Couplée à TanStack Start et son plugin tanstackStartCookies, elle s’intègre en moins d’une heure. Ce tutoriel détaille l’installation, la config Drizzle, la route API catch-all, le client React, les helpers getSession, le pattern de protection de routes et l’ajout d’un provider OAuth (GitHub) en bonus.

Article de la série autour de TanStack Start en production 2026.

Prérequis

Avant de commencer, le projet doit déjà tourner sous TanStack Start. Si ce n’est pas le cas, le tutoriel Setup TanStack Start v1 couvre les étapes initiales.

  • Node.js 22 LTS, npm 10+ ou pnpm 9+.
  • Un projet TanStack Start v1 fonctionnel avec TypeScript.
  • Une base de données accessible (PostgreSQL local, Neon, Supabase, ou D1 sur Cloudflare).
  • Drizzle ORM installé et un schéma initial.
  • Connaissances de base sur les sessions HTTP et les cookies.

Étape 1 — Installer better-auth et préparer Drizzle

Trois paquets sont nécessaires : better-auth pour la lib elle-même, drizzle-orm pour la couche d’accès base, et le driver correspondant à votre base (pg pour PostgreSQL, better-sqlite3 pour SQLite local ou rien pour D1 qui utilise l’API native).

npm install better-auth drizzle-orm
npm install pg
npm install -D drizzle-kit @types/pg

Une fois installé, on génère les variables d’environnement nécessaires. better-auth a besoin d’un secret pour signer les cookies de session et d’une URL de base. Créez un fichier .env à la racine.

# .env
BETTER_AUTH_SECRET=$(openssl rand -base64 32)
BETTER_AUTH_URL=http://localhost:3000
DATABASE_URL=postgres://user:pass@localhost:5432/mydb

Le secret doit être long et imprévisible — la commande openssl rand -base64 32 génère 32 octets aléatoires en base64. En production, ce secret vit dans les variables d’environnement de l’hébergeur (ou via wrangler secret put sur Cloudflare Workers comme expliqué dans le tutoriel Cloudflare Workers).

Étape 2 — Configurer better-auth avec Drizzle adapter

Le fichier de config central s’écrit une fois et alimente toute la suite. On y déclare l’adaptateur Drizzle qui pointe vers le schéma, le secret, l’URL de base et les plugins activés. Le plugin tanstackStartCookies est obligatoire et doit être le dernier de la liste : il intercepte les Set-Cookie et les renvoie via la réponse de la server function plutôt que via headers HTTP directs.

// src/lib/auth.ts
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { tanstackStartCookies } from 'better-auth/tanstack-start'
import { db } from '~/db/client'

export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: 'pg' }),
  secret: process.env.BETTER_AUTH_SECRET,
  baseURL: process.env.BETTER_AUTH_URL,
  emailAndPassword: { enabled: true },
  plugins: [tanstackStartCookies()], // doit rester en dernier
})

Le paramètre provider indique le SQL dialect cible : pg pour PostgreSQL, mysql, ou sqlite. Pour D1, utilisez sqlite avec un client Drizzle initialisé via drizzle-orm/d1. L’option emailAndPassword.enabled active la combinaison classique ; si vous voulez du passwordless uniquement, laissez à false et ajoutez le plugin magicLink à la place.

Étape 3 — Générer les tables d’authentification

better-auth crée automatiquement quatre tables : user, session, account, verification. Le schéma se génère via la CLI better-auth, qui produit un fichier Drizzle prêt à être importé. C’est l’approche recommandée parce qu’elle reste synchrone avec les évolutions de la bibliothèque.

npx @better-auth/cli generate --output ./src/db/auth-schema.ts

La commande lit votre auth.ts, voit qu’on utilise Drizzle, et écrit un fichier de schéma TypeScript avec les quatre tables et leurs relations. Vous l’importez ensuite dans votre db/schema.ts principal pour que drizzle-kit les inclue dans les migrations.

// src/db/schema.ts
export * from './auth-schema'
// ... vos autres tables métier

Lancez ensuite la migration habituelle de Drizzle pour créer les tables physiques. Le signal de succès : votre base contient bien les quatre tables, vérifiable avec psql ou un client GUI.

npx drizzle-kit generate
npx drizzle-kit migrate

Étape 4 — Exposer la route API catch-all /api/auth/$

better-auth fournit un handler unique qui répond à toutes les routes /api/auth/* : signin, signup, callback OAuth, etc. On expose ce handler via une route catch-all TanStack Router, qui capture toutes les requêtes derrière le préfixe et les délègue à auth.handler().

// src/routes/api/auth/$.ts
import { auth } from '~/lib/auth'
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/api/auth/$')({
  server: {
    handlers: {
      GET: async ({ request }: { request: Request }) => auth.handler(request),
      POST: async ({ request }: { request: Request }) => auth.handler(request),
    },
  },
})

Le splat $ attrape l’intégralité du chemin résiduel (/api/auth/sign-in/email, /api/auth/callback/github, etc.). better-auth dispatche en interne selon le chemin. Vous n’avez rien d’autre à écrire côté serveur — c’est le minimum d’intrusivité réclamé pour une auth full-stack.

Étape 5 — Créer le client React pour signIn/signUp/signOut

Côté navigateur, better-auth expose un client typé qu’on instancie une fois et qu’on importe partout. Il sait construire les bonnes URLs, gérer les redirections, parser les réponses. On l’isole dans un fichier dédié pour pouvoir le réutiliser depuis n’importe quel composant.

// src/lib/auth-client.ts
import { createAuthClient } from 'better-auth/react'

export const authClient = createAuthClient({
  baseURL: import.meta.env.VITE_AUTH_URL,
})

export const { signIn, signUp, signOut, useSession } = authClient

Le hook useSession() est particulièrement pratique côté composant : il retourne { data, isPending, error } et se met à jour automatiquement après un signIn ou signOut. La session est aussi utilisable côté SSR via une server function dédiée, comme on va le voir.

Étape 6 — Construire un formulaire d’inscription

On combine TanStack Form avec le client auth pour un formulaire d’inscription propre. Le code suivant peut servir de base — adaptez-le à votre design système. La validation est faite par Zod côté client (UX) et par better-auth côté serveur (sécurité, contraintes uniqueness).

// src/routes/(auth)/signup.tsx
import { createFileRoute, useRouter } from '@tanstack/react-router'
import { useForm } from '@tanstack/react-form'
import { z } from 'zod'
import { signUp } from '~/lib/auth-client'

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  name: z.string().min(2),
})

export const Route = createFileRoute('/(auth)/signup')({
  component: SignupPage,
})

function SignupPage() {
  const router = useRouter()
  const form = useForm({
    defaultValues: { email: '', password: '', name: '' },
    validators: { onChange: schema, onSubmit: schema },
    onSubmit: async ({ value }) => {
      const { error } = await signUp.email(value)
      if (error) throw new Error(error.message)
      router.navigate({ to: '/dashboard' })
    },
  })

  return (
    <form onSubmit={(e) => { e.preventDefault(); void form.handleSubmit() }}>
      {/* form.Field pour email, password, name */}
    </form>
  )
}

L’appel signUp.email(value) envoie les données à /api/auth/sign-up/email ; better-auth crée l’utilisateur, hashe le mot de passe avec scrypt par défaut, ouvre une session, et renvoie le cookie. Le navigateur le stocke automatiquement, et au refresh suivant l’utilisateur reste connecté. Pour le pattern complet de formulaire avec Zod, voyez le tutoriel TanStack Form + Zod.

Étape 7 — Récupérer la session côté serveur

La session est utile aussi bien dans les beforeLoad que dans les server functions métier. Pour la lire côté serveur, on écrit un helper qui appelle l’API interne de better-auth avec les headers de la requête courante. C’est lui qu’on appellera partout où on a besoin de connaître l’utilisateur.

// src/lib/auth-functions.ts
import { createServerFn } from '@tanstack/react-start'
import { getRequestHeaders } from '@tanstack/react-start/server'
import { auth } from './auth'

export const getSession = createServerFn({ method: 'GET' }).handler(async () => {
  const headers = getRequestHeaders()
  return await auth.api.getSession({ headers })
})

getRequestHeaders() récupère les headers de la requête HTTP en cours, dont le cookie de session. better-auth les inspecte, vérifie la signature, charge l’utilisateur depuis la table session et retourne { user, session } ou null. Cette fonction tourne uniquement côté serveur, jamais bundlée côté client — vos secrets restent là où ils doivent être.

Étape 8 — Protéger une zone d’application

Pour empêcher un utilisateur non connecté d’accéder au dashboard, on utilise le hook beforeLoad de TanStack Router. Il s’exécute avant le rendu de la route et peut lever redirect() pour envoyer l’utilisateur ailleurs. Le pattern idiomatique consiste à créer un layout pathless _protected.tsx qui fait la vérification une seule fois pour toutes ses routes filles.

// src/routes/_protected.tsx
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import { getSession } from '~/lib/auth-functions'

export const Route = createFileRoute('/_protected')({
  beforeLoad: async ({ location }) => {
    const session = await getSession()
    if (!session) {
      throw redirect({
        to: '/login',
        search: { redirect: location.href },
      })
    }
    return { user: session.user }
  },
  component: () => <Outlet />,
})

Toute route placée sous src/routes/_protected/ hérite de cette protection. Au premier accès non authentifié, l’utilisateur est redirigé vers /login?redirect=/dashboard. Une fois connecté, il revient au chemin original. Le return { user } rend l’utilisateur disponible dans le contexte du router : tout composant fils peut l’extraire via Route.useRouteContext().

Étape 9 — Ajouter un provider OAuth (GitHub)

Les providers OAuth se déclarent dans la config better-auth. Pour GitHub, créez une OAuth App dans les settings de votre compte GitHub avec l’URL callback http://localhost:3000/api/auth/callback/github. Récupérez le Client ID et le Client Secret dans .env.

// src/lib/auth.ts (extrait avec GitHub)
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { tanstackStartCookies } from 'better-auth/tanstack-start'
import { db } from '~/db/client'

export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: 'pg' }),
  secret: process.env.BETTER_AUTH_SECRET,
  baseURL: process.env.BETTER_AUTH_URL,
  emailAndPassword: { enabled: true },
  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    },
  },
  plugins: [tanstackStartCookies()],
})

Côté composant, le bouton se résume à un onClick qui déclenche le flow OAuth. better-auth gère la redirection vers GitHub, le retour, l’échange du code, la création du user dans la table account liée au user, et l’ouverture de la session.

import { signIn } from '~/lib/auth-client'

<button
  onClick={() => signIn.social({ provider: 'github', callbackURL: '/dashboard' })}
>
  Continuer avec GitHub
</button>

Au clic, l’utilisateur part sur github.com/login/oauth/authorize, autorise l’app, revient sur /api/auth/callback/github, et atterrit sur /dashboard connecté. La même mécanique vaut pour les autres providers : Google, Apple, Discord, X, Microsoft, Facebook, etc. — better-auth en supporte une vingtaine en 2026.

Étape 10 — Déconnexion et nettoyage

La déconnexion est trivialement simple côté client. La méthode signOut() du client invalide la session côté serveur, supprime le cookie côté navigateur et remet useSession à null. On l’attache à un bouton et on redirige vers la home après.

import { signOut } from '~/lib/auth-client'
import { useRouter } from '@tanstack/react-router'

function LogoutButton() {
  const router = useRouter()
  return (
    <button
      onClick={async () => {
        await signOut()
        router.navigate({ to: '/' })
      }}
    >
      Se déconnecter
    </button>
  )
}

Côté serveur, la session est immédiatement invalidée — un autre onglet qui appelle getSession() juste après reçoit null. Pour la sécurité, better-auth gère aussi le rate limiting des tentatives de connexion et l’expiration glissante des sessions sans configuration supplémentaire.

Erreurs fréquentes

Erreur Cause Solution
Cookie de session perdu Plugin tanstackStartCookies manquant L’ajouter en dernier des plugins dans auth.ts
Tables not found Migrations Drizzle non appliquées Lancer drizzle-kit generate puis migrate
OAuth callback 404 URL callback mal configurée chez le provider Vérifier BETTER_AUTH_URL + URL callback côté GitHub/Google
Session ne survit pas au refresh Cookie domain ou secure mal configuré En prod, utiliser HTTPS et ajuster cookieOptions
Erreur CORS sur OAuth baseURL ne correspond pas à l’origine réelle Aligner BETTER_AUTH_URL avec l’URL de l’app
signIn.social ne déclenche pas la redirection Provider non déclaré dans socialProviders Ajouter le provider dans auth.ts

FAQ

Faut-il une server function pour chaque appel auth ? Non, le client better-auth/react les expose déjà tous (signIn, signUp, signOut, updateUser, etc.). Réservez les server functions aux opérations métier qui nécessitent l’utilisateur courant.

Comment activer la 2FA ? Ajoutez le plugin twoFactor() dans plugins: [...]. Le client expose alors authClient.twoFactor.enable() qui retourne un QR code pour l’authenticator.

Peut-on personnaliser la table user (champ role, orgId) ? Oui, via l’option user: { additionalFields: { role: { type: 'string' } } } dans la config. better-auth ajoute ces colonnes lors de la prochaine génération de schéma.

Comment gérer les organisations multi-tenant ? Utilisez le plugin organization() qui crée les tables organization, member, invitation et expose des helpers pour gérer les rôles.

better-auth fonctionne-t-il sur Cloudflare Workers ? Oui, à condition d’utiliser un client Drizzle compatible (D1 ou Hyperdrive vers PostgreSQL). Voir le tutoriel sur le déploiement Cloudflare.

Pour aller plus loin

Ressources

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité