(2026/6/9追記) 本記事の公開後にセキュリティ運用の見直しを行い、§2と §4にそれぞれ追記を加えています。最新の推奨構成は各セクションの追記をご確認ください。(追記ここまで)

Better Auth が Auth.js を公式に引き継いだ発表

2025年9月付近で、Auth.js(旧NextAuth.js、以下NextAuth)の公式発表がありました。Better Auth(npmパッケージ名: better-auth、以下better-auth)チームに引き継がれるという内容です。

発表された概要は以下のとおりです。

  • Auth.jsのセキュリティ修正やクリティカルな対応は継続される
  • 新機能・今後の進化は Better Auth 側に集約される

NextAuthの開発メンバーが関わる形でBetter Authへ収束していく方針です。

既存のプロジェクトであればすぐさまbetter-authに切り替える必要はありませんが、新規のプロジェクトであればbetter-authも有力な選択肢です。

今回、新規プロジェクトの立ち上げにあたり、この公式発表を受けてbetter-authを採用しました。NextAuth v5では jwt コールバックにリフレッシュ処理を集約していましたが、ロジックの肥大化により保守が困難になっていました。better-authのプラグインベースの設計に魅力を感じたことも決め手の1つです。

このドキュメントは、Next.js App RouterTanStack Query を採用したプロジェクトを対象としています。バックエンドAPIが「JWT + リフレッシュトークン」を発行するステートレスな構成において、NextAuth (v5) からbetter-authへ移行する際のノウハウを逆引き形式でまとめました。

これからbetter-authの導入や移行を検討しているエンジニアの方は、ぜひ参考にしてください。

動作確認環境

ライブラリバージョン
Next.js16.0.7
React19.x
better-auth1.4.18
@tanstack/react-query5.75.4
Node.js24.11.1

1. 初期設定・ルーティング編

Q. フロントエンドでセッション情報を共有したい(Providerの配置)

NextAuth: ルートレイアウトなどに <SessionProvider> を配置してReact Contextでセッションを共有する必要がありました。

better-auth: Providerは一切不要(削除)です。better-authはCookieベースで動作し、内部的に nanostores のAtomを利用して各コンポーネントに状態を共有します。

補足: なぜ Provider なしで動くのか? NextAuthはReact Context(Provider → Consumer)でセッションを配信していました。better-authはReactの外にあるグローバルストア(nanostoresのAtom)にセッション状態を保持し、useSession() はそのAtomをsubscribeするだけです。Reactツリーに依存しないためProviderが不要になります。初回マウント時にCookieから /api/auth/get-session をfetchしてAtomを初期化し、以降はAtomの値を返します。

Q. API Route のエンドポイントを作りたい

NextAuth: src/app/api/auth/[...nextauth]/route.ts に配置していました。

better-auth: ディレクトリ名を [...all] に変更し、src/app/api/auth/[...all]/route.ts とします。中身は toNextJsHandler を用いてエクスポートします。


2. 型定義・スキーマ編

初期設定が整ったところで、次に型定義の変更点を見ていきます。

Q. セッションに独自のカスタムフィールドを追加して型推論させたい

NextAuth: next-auth.d.ts を作成し、declare module によるモジュール拡張(Module Augmentation)で型を後付けする必要がありました。ロジックと型定義が分離しがちでした。

better-auth: プラグインの schema が型の「Source of Truth」です。クライアント側で inferAdditionalFields<typeof auth>() を使うだけで、戻り値にカスタムフィールドの型が 自動推論(Schema Inference) されます。.d.ts ファイルを手動更新する手間は不要です。

Q. セッションオブジェクトの構造はどう変わる?

NextAuthではセッションオブジェクトがフラットな構造でした。better-authでは sessionuser がネストされた構造に変わります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ===== NextAuth =====
{
  user: { name, email, image },
  accessToken: "eyJhbG...",
  expires: "2026-03-20T..."
}

// ===== better-auth =====
{
  session: {
    id: "uuid",
    userId: "user-id",
    accessToken: "eyJhbG...",            // プラグインで追加したフィールド
    refreshToken: "eyJhbG...",
    accessTokenExpiresAt: 1772519329,    // ATのJWTをデコードして得た exp
    refreshTokenExpiresAt: 1772519629,   // RTのJWTをデコードして得た exp(自前で追加するフィールド)
  },
  user: {
    id: "user-id",
    name: "user-id",
    email: "user-id@placeholder.local",
  }
}

注意: session.accessToken のように1段階深くなるため、既存コードの参照箇所を一括で書き換える必要があります。

(2026/6/9追記)公開後のセキュリティ運用見直しで、AT/RTをクライアントJSから構造的に到達不能にする構成へ移行しました。session schemaにはトークンを持たせず、AT/RTはHttpOnly + 暗号化された専用Cookie(account_data)に分離して保管します。sessionは userId などの識別情報だけを持ちます。クライアントは同一OriginのBFF(Backend for Frontend)プロキシ(/api/proxy/*)を呼び出すだけです。プロキシがサーバー側でCookieからトークンを取り出し、Authorization: Bearer を付与してバックエンドへ転送します。これにより fetch('/api/auth/get-session') 経由でもトークンは取得できず、XSSや悪意ある拡張機能による持ち出しを根本的に防げます。当初は customSession でsession応答からRTを除去する方式を採りましたが、保管先そのものを分離する現構成へ発展させています。実装の詳細は better-auth 公式ドキュメント を参照してください。(追記ここまで)


3. セッション取得・状態更新編

型定義の違いを押さえたところで、実際にセッションを取得・更新する方法の違いを確認します。

Q. サーバーコンポーネント(SSR/RSC)でセッションを取得したい

NextAuth: auth() を呼び出すだけで取得可能でした。

better-auth: Cookieを参照する都合上、auth.api.getSession({ headers }) でヘッダーを明示的に渡す必要があります。

※Edge Runtimeで動くミドルウェアでは auth オブジェクトを直接インポートできません。auth.ts が依存するライブラリ(PrismaなどのDBドライバ)がEdge環境に対応していないことが多いためです。代わりに、Cookieのみを解析する getSessionCookie や、その結果を保持する getCookieCache を使用します。ビルドエラーを回避しつつ高速な認証チェックが可能です。

Q. ログイン直後に画面のセッション状態を最新化したい

better-auth特有の注意点: カスタムエンドポイント(authClient.$fetch など)経由でログインした場合、内部のAtom(グローバルストア)は自動更新されません。ログイン成功後に useSession().refetch() を実行し、明示的にセッションを再取得してください。


4. トークン管理・リフレッシュ編

セッションの取得方法が分かったところで、移行時に最も設計判断が求められるトークン管理について解説します。

このセクションの前提: better-authは「セッション管理の基盤」を提供しますが、バックエンド API と連携するトークンリフレッシュの仕組みは組み込まれていません。NextAuthでは jwt コールバックに全部入りで書けましたが、better-authでは以下を個別に設計・実装する必要があります。

自前で作るもの役割対応ファイル例
カスタムプラグインサインイン・トークン交換・リフレッシュの3エンドポイントを定義。バックエンドAPIとのブリッジauth-plugin.ts
getAccessToken()APIリクエスト前にトークンを取得する関数。キャッシュ確認 → セッション取得 → リフレッシュ判定を行うauth-token.ts
customFetchOrval等のAPI自動生成ツールが使うfetchラッパー。getAccessToken() を呼んで Authorization ヘッダーを自動付与するcustom-fetch.ts
トークンキャッシュ + signOut ラッパーモジュールスコープの変数によるキャッシュと、キャッシュクリア付きのサインアウト関数auth-token.ts
JWT デコードユーティリティバックエンドが発行する AT・RT の JWT から exp(有効期限)を抽出する。refreshTokenExpiresAt の取得に必須jwt.ts
ミドルウェア(ルート保護)getCookieCache でセッションを取得し、認証必須パスへの未認証アクセスをリダイレクトするmiddleware.ts

better-authはセッション管理やプラグインシステムなどの基盤を提供しますが、上記コンポーネントの構築は開発者の責任です。

Q. トークンをリフレッシュさせたい

NextAuth: サーバーサイドの jwt コールバック内で、期限切れを検知して暗黙的に自動実行させるのが一般的でした。

better-auth: サーバーサイドでのリフレッシュは行わず、クライアントサイドからの明示的な呼び出しに委譲します。

理由: サーバーサイドには同時リクエストを制御する仕組み(Mutex)がないため、二重リフレッシュのリスクがあるからです。また、SSR中にリフレッシュトークンを消費すると Set-Cookie がブラウザに確実に反映される保証がありません。

補足: SSR でトークンが期限切れだったら画面はどうなる? サーバーサイドではトークン取得関数が null を返します。そのためSSRではデータなし(ローディング状態)のHTMLを返却します。ハイドレーション後、TanStack Queryがクライアント側でリフレッシュ・再取得するため、初回表示が一瞬ローディングになるだけです。

対策:

  • タイマーやポーリングは使わない。APIリクエストのたびに呼ばれる getAccessToken() 内で「残り期限が60秒未満か?」をチェックし、条件を満たした場合だけリフレッシュを実行する(オンデマンド方式)
  • TanStack Queryによる複数クエリの同時発火でリフレッシュAPIが多重に呼ばれないよう、クライアント側でリフレッシュ用Promiseを共有するMutex制御が必須となる

補足: 「Mutex」と言ってもOSレベルの排他制御ではありません。 モジュールスコープに let refreshPromise: Promise | null を1つ持つだけのシンプルな実装です。リフレッシュ中なら後続は同じPromiseを await し、完了後 null へ戻します。

1
2
3
1件目のリクエスト → refreshPromise を作成
2件目・3件目 → 既存の refreshPromise を await(新しい fetch は発行しない)
完了 → refreshPromise = null に戻す
flowchart TD
    A[APIリクエスト発生] --> B["getAccessToken()"]
    B --> C{残り期限 60秒以上?}
    C -- Yes --> D[キャッシュから即返却]
    C -- No --> E{refreshPromise\nが存在する?}
    E -- Yes --> F[既存の Promise を await]
    E -- No --> G[リフレッシュ実行]
    G --> H[新トークンをキャッシュに保存]
    H --> I[新トークンで返却]
    F --> I

Q. リフレッシュトークン(RT)の有効期限も監視すべき?

はい。 ATだけでなく、RTの有効期限(refreshTokenExpiresAt)もセッション・キャッシュの両方で追跡し、どちらかが期限切れ間近であればリフレッシュをトリガーします。

RTの有効期限が短い場合(例: 5分)、ATの期限だけを監視しているとRTが先に失効してリフレッシュ自体が不可能になるリスクがあります。

1
2
3
4
リフレッシュ判定:
  shouldRefreshAccess = ATが期限切れ60秒前か?
  shouldRefreshRT     = RTが期限切れ60秒前か?
  → どちらかが true ならリフレッシュ実行

(2026/6/9追記)上記の「RT期限も監視する」方針は、§2で述べたAT/RT分離構成では不要です。AT/RTを専用Cookieに分離した後は、authClient.getSession() の戻り値にトークンが一切現れません。リフレッシュはすべてBFFプロキシがサーバー側で実行します(転送前の先制リフレッシュ + 401時の自動リトライ)。そのため、JS側でAT/RTの期限を監視する必要はありません。ログイン状態の判定は、トークンの有無ではなくセッションオブジェクト内の userId などの識別情報の存在で行います。(追記ここまで)

Q. リフレッシュが失敗したらどうなる?

NextAuth: セッションに error: 'RefreshAccessTokenError' を格納し、呼び出し元で判断する方式でした。

better-auth: グレースフルデグラデーションを採用しています。

グレースフル・デグラデーション(Graceful Degradation)とは、障害時に全機能を停止させず、利用可能な範囲でサービスを継続する設計方針です。ここでは、RTのリフレッシュに失敗してもATが有効な間は操作を継続できるようにする戦略を指します。

flowchart TD
    A["リフレッシュ失敗(!res.ok)"] --> B{AT はまだ有効?}
    B -- Yes --> C[AT をそのまま返す]
    C --> D[refreshTokenExpiresAt を省略して\nキャッシュに保存]
    D --> E[refreshFailed フラグを立てる]
    E --> F[操作を継続]
    B -- No --> G[キャッシュクリア]
    G --> H[ログインページにリダイレクト]

RTのリフレッシュに失敗しても、ATがまだ有効であれば即座にサインアウトせず、ATの残り期間で操作を継続できます。

補足: リフレッシュループの防止 バックエンドがシングルユース(使い捨て)RTを採用している場合、一度失敗したRTで再試行しても必ず失敗します。これを防ぐために2層の防御を実装しています。

  1. クライアント側(auth-token.ts): モジュールスコープに refreshFailed フラグを用意し、HTTPエラー応答(=サーバーのRT拒否)時にセット。フラグが立っている間はリフレッシュを試みず、AT有効ならそのまま返却、AT期限切れなら即サインアウト。フラグは signOut() でのみリセット(clearTokenCache() ではリセットしない)。なお、ネットワークエラー(一時的障害)ではフラグをセットしない。復旧後のリフレッシュ成功を見込んでの判断
  2. サーバー側(auth-plugin.ts): リフレッシュ失敗時に setSessionCookie で無効RTをセッションCookieから削除。ページリロード後も古いRTによるリフレッシュ再試行を防止

Q. リフレッシュ API に authClient.$fetch を使ったら 415 エラーになるのはなぜ?

リフレッシュAPIはセッションCookieからRTを読み取るためリクエストボディは不要です。しかしPOSTメソッドである以上、空オブジェクト {} を渡すことになります。authClient.$fetch は内部的にbetter-authの betterFetch を使用しています。ボディが実質空のPOSTでは Content-Type: application/json ヘッダーを省略する場合があります。サーバー側(createAuthEndpoint)はJSONボディを期待するため、Content-Type がないと 415 Unsupported Media Type を返します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// NG: authClient.$fetch → Content-Type が付かず 415 になる場合がある
await authClient.$fetch("/refresh-token", { method: "POST", body: {} });

// OK: 生の fetch で明示的にヘッダーを付ける
await fetch("/api/auth/refresh-token", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: "{}",
  credentials: "include", // Cookie 送信に必要
});

5. ログアウト編

トークン管理の設計が整ったら、最後にログアウト処理の違いを確認します。

Q. ログアウト処理を行いたい

NextAuth: signOut() を呼ぶだけで完了します。

better-auth: authClient.signOut() を直接呼ぶと、タブ単位で保持しているインメモリのトークンキャッシュが残ってしまう場合があります。必ずトークンキャッシュをクリアしてから signOut() を実行する独自のラッパー関数を作成して使用します。

補足: 「インメモリのトークンキャッシュ」とは? better-authが持つものではなく、自前で実装するモジュールスコープの変数です。authClient.getSession() は内部キャッシュの都合でリフレッシュ後も古いトークンを返す場合があります。そのためリフレッシュ成功時は自前のキャッシュを更新し、後続リクエストでは getSession() を呼ばず即座に返す仕組みです。タブ(ページ)ごとに独立しており、リロードで消えます。

1
2
3
4
export const signOut = async (): Promise<void> => {
  clearTokenCache(); // メモリキャッシュ破棄
  await authClient.signOut(); // Cookie 削除
};

6. 【おまけ】 import 置き換え早見表

よく使うメソッドのインポート元は以下のように変わります。

用途NextAuth (v5)better-auth
Hookimport { useSession } from 'next-auth/react'import { useSession } from '@/lib/auth-client'
ログインimport { signIn } from 'next-auth/react'authClient.$fetch('/sign-in/credentials', ...)
ログアウトimport { signOut } from 'next-auth/react'import { signOut } from '@/lib/auth-token'(※ラッパーを使用)
サーバー取得import { auth } from '@/lib/auth'auth()import { auth } from '@/lib/auth'auth.api.getSession({ headers })
SessionProviderimport { SessionProvider } from 'next-auth/react'不要(削除)
API Routeimport { handlers } from '@/lib/auth'import { toNextJsHandler } from 'better-auth/next-js'

7. 参考リンク(better-auth 公式ドキュメント)

本チートシートで触れた機能に対応する公式ドキュメントへのリンクです。

トピックURL本記事の関連セクション
Next.js integrationhttps://www.better-auth.com/docs/integrations/next1. API Route、3. SSR取得、ミドルウェア
Session Management(Cookie Cache / JWT Strategy)https://www.better-auth.com/docs/concepts/session-management1. Provider不要の背景、4. トークン管理全般
Custom Plugin の作り方https://www.better-auth.com/docs/guides/your-first-plugin4. カスタムプラグイン(sign-in / refresh エンドポイント)
型推論(inferAdditionalFields)https://www.better-auth.com/docs/concepts/typescript2. スキーマ推論

まとめ

better-authへの移行によって、型定義の二重管理から解放され、不要なProviderを削減できます。

一方で、以下のようにクライアントサイドでの状態・通信管理をより明示的に設計する必要がある点には注意が必要です。

注意点概要
自前実装の範囲が広いプラグイン・トークン管理・customFetch・JWT デコード・ミドルウェアなど、認証フロー全体を自分で組み立てる必要がある
リフレッシュの並行処理制御(Mutex)TanStack Queryの同時クエリ発火で多重リフレッシュが発生する
ログイン後の refetch()カスタムエンドポイント経由ではAtomが自動更新されない
空POSTの415エラー回避authClient.$fetch ではなく生の fetch + Content-Type ヘッダーを使用
セッション構造の変化フラット → ネスト(session.accessToken)への参照書き換え
AT・RT 両方の期限監視RTが先に失効するとリフレッシュ自体が不可能になる
リフレッシュ失敗のフォールバックATが有効なら即サインアウトせずグレースフルデグラデーション