mcpField
B
better-auth

better-auth

github.com/jezwebセキュリティ1.1k434 回インストール3 週間前に更新
リポジトリ

better-auth について

better-auth の実装ガイドで、Cloudflare D1 の設定、認証プラグイン、設定ミスの防止を扱います。アプリに認証を組み込む開発者向けです。

主な機能

  1. 01Drizzle または Kysely による D1 設定
  2. 02OAuth 2.1 Provider の設定ガイド
  3. 03セッションと JWT の設定
  4. 04プラグインとフレームワークの連携情報
  5. 05レート制限の設定例

活用シーン

  1. 01D1 データベースアダプターの選定
  2. 02OAuth Provider 機能の追加
  3. 03ステートレスセッションの設定
  4. 04フレームワークの Cookie 処理の調査

MCP 対応

MCP サーバー向け OAuth 2.1 Provider の構築方法を説明し、専用 MCP プラグインは非推奨としています。

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/jezweb/claude-skills --skill better-auth

インストール後、Claude Code で/better-authを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

README.md
.claude-plugin/
plugin.json
assets/
auth-flow-diagram.md
commands/
setup.md
references/
cloudflare-worker-drizzle.ts
cloudflare-worker-kysely.ts
database-schema.ts
hono-cloudflare.ts
react-client-hooks.tsx
references/nextjs/
README.md
postgres-example.ts
rules/
better-auth.md
scripts/
setup-d1-drizzle.sh

詳細

better-auth - D1 アダプターとエラー防止ガイド

パッケージ: [email protected](2026年1月21日) 破壊的変更: ESM 専用(v1.4.0)、管理者のなりすまし防止がデフォルトで有効(v1.4.6)、複数チームのテーブル変更(v1.3)、D1 には Drizzle/Kysely が必要(直接利用できるアダプターはありません)


⚠️ 重要:D1 アダプターの要件

better-auth には d1Adapter() がありません。次のいずれかを必ず使用してください。

  • Drizzle ORM(推奨):drizzleAdapter(db, { provider: "sqlite" })
  • Kysely:new Kysely({ dialect: new D1Dialect({ database: env.DB }) })

詳しくは、下記の問題 #1 を参照してください。


v1.4.10 の新機能(2025年12月31日)

主な機能:

  • OAuth 2.1 Provider プラグイン - 独自の OAuth プロバイダーを構築できます(MCP プラグインに代わるものです)
  • Patreon OAuth プロバイダー - Patreon によるソーシャルサインイン
  • Kick OAuth プロバイダー - リフレッシュトークンに対応
  • Vercel OAuth プロバイダー - Vercel でサインイン
  • グローバル backgroundTasks 設定 - パフォーマンス向上のための遅延アクション
  • フォームデータ対応 - fetch メタデータへのフォールバックを備えたメール認証
  • Stripe の機能強化 - 柔軟なサブスクリプションライフサイクル、disableRedirect オプション

Admin プラグインの更新:

  • ⚠️ 破壊的変更:管理者のなりすましはデフォルトで無効です(v1.4.6)
  • 権限ベースのユーザー更新に対応するロール
  • ロール型の推論を改善

セキュリティ修正:

  • 設定可能なサイズ制約による SAML XML パーサーの強化
  • プロバイダーごとのクロックスキューに対応した SAML アサーションのタイムスタンプ検証
  • SSO ドメイン検証済みプロバイダーの信頼
  • 非推奨アルゴリズムの拒否
  • 行 nonce の適用

📚 ドキュメント: https://www.better-auth.com/changelogs


v1.4.0 の新機能(2025年11月22日)

主な機能:

  • ステートレスなセッション管理 - データベースに保存しないセッション
  • ESM 専用パッケージ ⚠️ 破壊的変更:CommonJS はサポートされなくなりました
  • JWT キーローテーション - セキュリティ強化のための自動キーローテーション
  • SCIM プロビジョニング - エンタープライズ向けのユーザープロビジョニングプロトコル
  • @standard-schema/spec - 検証で ZodType に代わるもの
  • CaptchaFox との統合 - CAPTCHA の組み込みサポート
  • サーバーサイド IP の自動検出
  • Cookie ベースのアカウントデータ保存
  • 複数のパスキーオリジンに対応
  • RP 起点のログアウトエンドポイント(OIDC)

📚 ドキュメント: https://www.better-auth.com/changelogs


v1.3 の新機能(2025年7月)

主な機能:

  • SAML 2.0 による SSO - エンタープライズ向けシングルサインオン(独立した @better-auth/sso パッケージに移行)
  • 複数チームのサポート ⚠️ 破壊的変更:メンバーテーブルから teamId が削除され、新しい teamMembers テーブルが必要です
  • 追加フィールド - 組織/メンバー/招待モデル用のカスタムフィールド
  • パフォーマンスの改善とバグ修正

📚 ドキュメント: https://www.better-auth.com/blog/1-3


代替案:Kysely アダプターパターン

Drizzle より Kysely を使用したい場合:

ファイル: src/auth.ts

import { betterAuth } from "better-auth";
import { Kysely, CamelCasePlugin } from "kysely";
import { D1Dialect } from "kysely-d1";

type Env = {
  DB: D1Database;
  BETTER_AUTH_SECRET: string;
  // ... other env vars
};

export function createAuth(env: Env) {
  return betterAuth({
    secret: env.BETTER_AUTH_SECRET,

    // Kysely with D1Dialect
    database: {
      db: new Kysely({
        dialect: new D1Dialect({
          database: env.DB,
        }),
        plugins: [
          // CRITICAL: Required if using Drizzle schema with snake_case
          new CamelCasePlugin(),
        ],
      }),
      type: "sqlite",
    },

    emailAndPassword: {
      enabled: true,
    },

    // ... other config
  });
}

CamelCasePlugin を使う理由

Drizzle スキーマで snake_case のカラム名(例:email_verified)を使用していても、better-auth が camelCase(例:emailVerified)を想定している場合、CamelCasePlugin が両者を自動的に変換します。

⚠️ Cloudflare Workers に関する注意:D1 データベースのバインディングは、リクエストハンドラー(fetch() 関数)の内部でのみ利用できます。リクエストコンテキストの外で better-auth を初期化することはできません。ファクトリー関数パターンを使用してください。

// ❌ WRONG - DB binding not available outside request
const db = drizzle(env.DB, { schema }) // env.DB doesn't exist here
export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "sqlite" }) })

// ✅ CORRECT - Create auth instance per-request
export default {
  fetch(request, env, ctx) {
    const db = drizzle(env.DB, { schema })
    const auth = betterAuth({ database: drizzleAdapter(db, { provider: "sqlite" }) })
    return auth.handler(request)
  }
}

コミュニティによる検証:複数の本番環境での実装により、このパターンが確認されています(Medium、AnswerOverflow、公式 Hono の例)。


フレームワークとの統合

TanStack Start

⚠️ 重大:TanStack Start で Cookie を正しく設定するには、reactStartCookies プラグインが必要です。

import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { reactStartCookies } from "better-auth/react-start";

export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: "sqlite" }),
  plugins: [
    twoFactor(),
    organization(),
    reactStartCookies(), // ⚠️ MUST be LAST plugin
  ],
});

必要な理由:TanStack Start は特殊な Cookie 処理システムを使用します。このプラグインがないと、signInEmail() や signUpEmail() などの認証関数で Cookie が正しく設定されず、認証に失敗します。

重要:reactStartCookies プラグインは、配列の最後に配置する必要があります。

セッションの null 許容パターン:TanStack Start で useSession() を使用する場合、セッションオブジェクトは常に存在しますが、ログインしていないときは session.user と session.session が null になります。

const { data: session } = authClient.useSession()

// When NOT logged in:
console.log(session) // { user: null, session: null }
console.log(!!session) // true (unexpected!)

// Correct check:
if (session?.user) {
  // User is logged in
}

session だけでなく、必ず session?.user または session?.session を確認してください。これは想定された動作です(セッションオブジェクトのコンテナは常に存在します)。

API ルートの設定(/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: ({ request }) => auth.handler(request),
      POST: ({ request }) => auth.handler(request),
    },
  },
})

📚 公式ドキュメント:https://www.better-auth.com/docs/integrations/tanstack


利用可能なプラグイン(v1.4+)

Better Auth は、高度な認証機能を提供するプラグインを用意しています。

プラグインインポート説明ドキュメント
OAuth 2.1 Providerbetter-auth/pluginsPKCE、JWT トークン、同意フローを備えた OAuth 2.1 プロバイダーを構築します(MCP および OIDC プラグインを置き換えます)📚
SSObetter-auth/pluginsOIDC、OAuth2、SAML 2.0 に対応したエンタープライズ向けシングルサインオン📚
Stripebetter-auth/plugins柔軟なライフサイクル管理に対応した決済およびサブスクリプション管理📚
MCPbetter-auth/plugins⚠️ 非推奨 — 代わりに OAuth 2.1 Provider を使用してください📚
Expobetter-auth/expowebBrowserOptions と最終ログイン方法の追跡に対応した React Native/Expo📚
OAuth 2.1 Provider プラグイン(v1.4.9 で追加)

MCP サーバー、サードパーティアプリ、API アクセス向けに独自の OAuth プロバイダーを構築できます。

import { betterAuth } from "better-auth";
import { oauthProvider } from "better-auth/plugins";
import { jwt } from "better-auth/plugins";

export const auth = betterAuth({
  plugins: [
    jwt(), // Required for token signing
    oauthProvider({
      // Token expiration (seconds)
      accessTokenExpiresIn: 3600,      // 1 hour
      refreshTokenExpiresIn: 2592000,  // 30 days
      authorizationCodeExpiresIn: 600, // 10 minutes
    }),
  ],
});

主な機能:

  • OAuth 2.1 準拠 — PKCE が必須で、S256 のみを使用し、インプリシットフローには対応しません
  • 3 種類のグラントタイプ:authorization_code、refresh_token、client_credentials
  • JWT または不透明トークン — トークン形式を設定できます
  • 動的クライアント登録 — RFC 7591 に準拠しています
  • 同意管理 — 信頼されたクライアントの同意をスキップできます
  • OIDC UserInfo エンドポイント — スコープに基づくクレームを返す /oauth2/userinfo

必須の Well-Known エンドポイント:

// app/api/.well-known/oauth-authorization-server/route.ts
export async function GET() {
  return Response.json({
    issuer: process.env.BETTER_AUTH_URL,
    authorization_endpoint: `${process.env.BETTER_AUTH_URL}/api/auth/oauth2/authorize`,
    token_endpoint: `${process.env.BETTER_AUTH_URL}/api/auth/oauth2/token`,
    // ... other metadata
  });
}

OAuth クライアントの作成:

const client = await auth.api.createOAuthClient({
  body: {
    name: "My MCP Server",
    redirectURLs: ["https://claude.ai/callback"],
    type: "public", // or "confidential"
  },
});
// Returns: { clientId, clientSecret (if confidential) }

📚 詳細なドキュメント:https://www.better-auth.com/docs/plugins/oauth-provider

⚠️ 注意:このプラグインは現在開発中であり、まだ本番環境での使用には適さない可能性があります。


その他のプラグインの一覧
プラグイン説明ドキュメント
BearerAPI トークン認証(API で Cookie を使う場合の代替手段)📚
One Tap手軽な Google One Tap サインイン📚
SCIMエンタープライズ向けユーザープロビジョニング(SCIM 2.0)📚
Anonymous個人を特定できる情報なしでのゲストユーザーアクセス📚
Usernameユーザー名ベースのサインイン(メールアドレスの代替手段)📚
Generic OAuthPKCE に対応したカスタム OAuth プロバイダー📚
Multi-Session同じブラウザーでの複数アカウントの利用📚
API Keyレート制限に対応したトークンベースの認証📚
Bearer Token プラグイン

API 専用の認証(モバイルアプリ、CLI ツール、サードパーティ連携)向け:

import { bearer } from "better-auth/plugins";
import { bearerClient } from "better-auth/client/plugins";

// Server
export const auth = betterAuth({
  plugins: [bearer()],
});

// Client - Store token after sign-in
const { token } = await authClient.signIn.email({ email, password });
localStorage.setItem("auth_token", token);

// Client - Configure fetch to include token
const authClient = createAuthClient({
  plugins: [bearerClient()],
  fetchOptions: {
    auth: { type: "Bearer", token: () => localStorage.getItem("auth_token") },
  },
});
Google One Tap Plugin

すでに Google にサインインしているユーザーが、ワンタップでスムーズにサインインできます。

import { oneTap } from "better-auth/plugins";
import { oneTapClient } from "better-auth/client/plugins";

// Server
export const auth = betterAuth({
  plugins: [oneTap()],
});

// Client
authClient.oneTap({
  onSuccess: (session) => {
    window.location.href = "/dashboard";
  },
});

要件:Google Cloud Console で承認済みの JavaScript オリジンを設定してください。

Anonymous Plugin

メールアドレスやパスワードを求めずにゲストアクセスを提供します。

import { anonymous } from "better-auth/plugins";

// Server
export const auth = betterAuth({
  plugins: [
    anonymous({
      emailDomainName: "anon.example.com", // temp@{id}.anon.example.com
      onLinkAccount: async ({ anonymousUser, newUser }) => {
        // Migrate anonymous user data to linked account
        await migrateUserData(anonymousUser.id, newUser.id);
      },
    }),
  ],
});

// Client
await authClient.signIn.anonymous();
// Later: user can link to real account via signIn.social/email
Generic OAuth Plugin

組み込みリストにないカスタム OAuth プロバイダーを追加します。

import { genericOAuth } from "better-auth/plugins";

export const auth = betterAuth({
  plugins: [
    genericOAuth({
      config: [
        {
          providerId: "linear",
          clientId: env.LINEAR_CLIENT_ID,
          clientSecret: env.LINEAR_CLIENT_SECRET,
          discoveryUrl: "https://linear.app/.well-known/openid-configuration",
          scopes: ["openid", "email", "profile"],
          pkce: true, // Recommended
        },
      ],
    }),
  ],
});

コールバック URL のパターン:{baseURL}/api/auth/oauth2/callback/{providerId}


レート制限

カスタマイズ可能なルールを備えた組み込みのレート制限です。

export const auth = betterAuth({
  rateLimit: {
    window: 60,  // seconds (default: 60)
    max: 100,    // requests per window (default: 100)

    // Custom rules for sensitive endpoints
    customRules: {
      "/sign-in/email": { window: 10, max: 3 },
      "/two-factor/*": { window: 10, max: 3 },
      "/forget-password": { window: 60, max: 5 },
    },

    // Use Redis/KV for distributed systems
    storage: "secondary-storage", // or "database"
  },

  // Secondary storage for rate limiting
  secondaryStorage: {
    get: async (key) => env.KV.get(key),
    set: async (key, value, ttl) => env.KV.put(key, value, { expirationTtl: ttl }),
    delete: async (key) => env.KV.delete(key),
  },
});

注:auth.api.* を介したサーバーサイドの呼び出しには、レート制限が適用されません。


ステートレスセッション(v1.4.0+)

データベースに保存せず、署名付き Cookie のみにセッションを保存します。

export const auth = betterAuth({
  session: {
    // Stateless: No database storage, session lives in cookie only
    storage: undefined, // or omit entirely

    // Cookie configuration
    cookieCache: {
      enabled: true,
      maxAge: 60 * 60 * 24 * 7, // 7 days
      encoding: "jwt", // Use JWT for stateless (not "compact")
    },

    // Session expiration
    expiresIn: 60 * 60 * 24 * 7, // 7 days
  },
});

使用する場面:

ストレージの種類使用例トレードオフ
ステートレス(Cookie のみ)読み取りの多いアプリ、エッジ/サーバーレス、失効が不要な場合セッションを失効できない、ペイロードサイズに制限がある
D1 データベース完全なセッション管理、監査証跡、失効結果整合性の問題
KV ストレージ強い整合性、高い読み取りパフォーマンス追加のバインディング設定

主なポイント:

  • ステートレスセッションは失効できません(ユーザーは有効期限まで待つ必要があります)
  • Cookie のサイズ上限は約 4KB です(セッションデータが制限されます)
  • 相互運用性には encoding: "jwt"、暗号化には "jwe" を使用してください
  • すべてのインスタンスで、サーバーの BETTER_AUTH_SECRET を同一にする必要があります

JWT キーローテーション(v1.4.0+)

セキュリティを強化するため、JWT 署名キーを自動的にローテーションします。

import { jwt } from "better-auth/plugins";

export const auth = betterAuth({
  plugins: [
    jwt({
      // Key rotation (optional, enterprise security)
      keyRotation: {
        enabled: true,
        rotationInterval: 60 * 60 * 24 * 30, // Rotate every 30 days
        keepPreviousKeys: 3, // Keep 3 old keys for validation
      },

      // Custom signing algorithm (default: HS256)
      algorithm: "RS256", // Requires asymmetric keys

      // JWKS endpoint (auto-generated at /api/auth/jwks)
      exposeJWKS: true,
    }),
  ],
});

主なポイント:

  • キーローテーションにより、侵害されたキーが無期限に有効になるのを防ぎます
  • 既存のトークンを検証できるよう、古いキーは一時的に保持されます
  • 外部サービス向けの JWKS エンドポイントは /api/auth/jwks です
  • 公開鍵による検証(マイクロサービス)には RS256 を使用してください
  • 単一サービスのアプリには HS256(デフォルト)を使用してください

プロバイダーのスコープ一覧

ユーザーデータの取得に必要な、一般的な OAuth プロバイダーとスコープです。

プロバイダースコープ取得内容
Googleopenidユーザー ID のみ
emailメールアドレス、email_verified
profile名前、アバター(picture)、ロケール
GitHubuser:emailメールアドレス(非公開の場合があります)
read:user名前、アバター、プロフィール URL、自己紹介
Microsoftopenidユーザー ID のみ
emailメールアドレス
profile名前、ロケール
User.ReadGraph API からの完全なプロフィール
Discordidentifyユーザー名、アバター、識別子
emailメールアドレス
Applename名/姓(初回認証時のみ)
emailメールアドレスまたはリレーアドレス
Patreonidentityユーザー ID、名前
identity[email]メールアドレス
Vercel(自動)メールアドレス、名前、アバター

設定例:

socialProviders: {
  google: {
    clientId: env.GOOGLE_CLIENT_ID,
    clientSecret: env.GOOGLE_CLIENT_SECRET,
    scope: ["openid", "email", "profile"], // All user data
  },
  github: {
    clientId: env.GITHUB_CLIENT_ID,
    clientSecret: env.GITHUB_CLIENT_SECRET,
    scope: ["user:email", "read:user"], // Email + full profile
  },
  microsoft: {
    clientId: env.MS_CLIENT_ID,
    clientSecret: env.MS_CLIENT_SECRET,
    scope: ["openid", "email", "profile", "User.Read"],
  },
}

セッション Cookie のキャッシュ

セッション Cookie には 3 種類のエンコード方式があります。

方式形式使用例
Compact(デフォルト)Base64url + HMAC-SHA256最小サイズで最速
JWT標準 JWT相互運用性
JWEA256CBC-HS512 による暗号化最も安全
export const auth = betterAuth({
  session: {
    cookieCache: {
      enabled: true,
      maxAge: 300, // 5 minutes
      encoding: "compact", // or "jwt" or "jwe"
    },
    freshAge: 60 * 60 * 24, // 1 day - operations requiring fresh session
  },
});

新しいセッション:一部の機密性の高い操作には、作成から時間が経っていないセッションが必要です。この期間は freshAge で設定します。


新しいソーシャルプロバイダー(v1.4.9+)

socialProviders: {
  // Patreon - Creator economy
  patreon: {
    clientId: env.PATREON_CLIENT_ID,
    clientSecret: env.PATREON_CLIENT_SECRET,
    scope: ["identity", "identity[email]"],
  },

  // Kick - Streaming platform (with refresh tokens)
  kick: {
    clientId: env.KICK_CLIENT_ID,
    clientSecret: env.KICK_CLIENT_SECRET,
  },

  // Vercel - Developer platform
  vercel: {
    clientId: env.VERCEL_CLIENT_ID,
    clientSecret: env.VERCEL_CLIENT_SECRET,
  },
}

Cloudflare Workers の要件

⚠️ 重要:Cloudflare Workers では AsyncLocalStorage のサポートが必要です。

# wrangler.toml
compatibility_flags = ["nodejs_compat"]
# or for older Workers:
# compatibility_flags = ["nodejs_als"]

このフラグがないと、better-auth はコンテキスト関連のエラーで失敗します。


データベースフック

データベース操作中にカスタムロジックを実行します。

export const auth = betterAuth({
  databaseHooks: {
    user: {
      create: {
        before: async (user, ctx) => {
          // Validate or modify before creation
          if (user.email?.endsWith("@blocked.com")) {
            throw new APIError("BAD_REQUEST", { message: "Email domain not allowed" });
          }
          return { data: { ...user, role: "member" } };
        },
        after: async (user, ctx) => {
          // Send welcome email, create related records, etc.
          await sendWelcomeEmail(user.email);
          await createDefaultWorkspace(user.id);
        },
      },
    },
    session: {
      create: {
        after: async (session, ctx) => {
          // Audit logging
          await auditLog.create({ action: "session_created", userId: session.userId });
        },
      },
    },
  },
});

利用可能なフック:user、session、account、verification テーブルの create、update。


Expo/React Native との連携

モバイル連携の完全なパターンです。

// Client setup with secure storage
import { expoClient } from "@better-auth/expo";
import * as SecureStore from "expo-secure-store";

const authClient = createAuthClient({
  baseURL: "https://api.example.com",
  plugins: [expoClient({ storage: SecureStore })],
});

// OAuth with deep linking
await authClient.signIn.social({
  provider: "google",
  callbackURL: "myapp://auth/callback", // Deep link
});

// Or use ID token verification (no redirect)
await authClient.signIn.social({
  provider: "google",
  idToken: {
    token: googleIdToken,
    nonce: generatedNonce,
  },
});

// Authenticated requests
const cookie = await authClient.getCookie();
await fetch("https://api.example.com/data", {
  headers: { Cookie: cookie },
  credentials: "omit",
});

app.json のディープリンク設定:

{
  "expo": {
    "scheme": "myapp"
  }
}

サーバーの trustedOrigins(開発環境):

trustedOrigins: ["exp://**", "myapp://"]

API リファレンス

概要:無料で利用できる機能

auth.handler() を呼び出すと、better-auth は本番環境ですぐに使える 80 以上の REST エンドポイントを /api/auth/* に自動的に公開します。すべてのエンドポイントは、プログラムから利用できる サーバーサイドメソッドとして auth.api.* からも使用できます。

この二層構造の API システムにより、次のことが可能になります。

  • クライアント(React、Vue、モバイルアプリ)は HTTP エンドポイントを直接呼び出します
  • サーバーサイドコード(ミドルウェア、バックグラウンドジョブ)は auth.api.* メソッドを使用します
  • 定型コードは不要 — 認証エンドポイントを手動で作成する必要はありません

時間の節約:ゼロから構築する場合は約 220 時間かかります。better-auth を使えば約 4〜8 時間です。97%削減できます。


自動生成される HTTP エンドポイント

auth.handler() を使用すると、すべてのエンドポイントが /api/auth/* に自動的に公開されます。

認証のコアエンドポイント
エンドポイントメソッド説明
/sign-up/emailPOSTメールアドレスとパスワードで登録
/sign-in/emailPOSTメールアドレスとパスワードで認証
/sign-outPOSTユーザーをログアウト
/change-passwordPOSTパスワードを更新(現在のパスワードが必要)
/forget-passwordPOSTパスワードリセットフローを開始
/reset-passwordPOSTトークンを使ってパスワードリセットを完了
/send-verification-emailPOSTメールアドレス確認リンクを送信
/verify-emailGETトークンを使ってメールアドレスを確認(?token=<token>)
/get-sessionGET現在のセッションを取得
/list-sessionsGETユーザーのアクティブなセッションをすべて取得
/revoke-sessionPOST指定したセッションを終了
/revoke-other-sessionsPOST現在のセッション以外のすべてのセッションを終了
/revoke-sessionsPOSTユーザーのすべてのセッションを終了
/update-userPOSTユーザープロフィールを変更(名前、画像)
/change-emailPOSTメールアドレスを更新
/set-passwordPOSTOAuth のみのアカウントにパスワードを追加
/delete-userPOSTユーザーアカウントを削除
/list-accountsGETリンク済みの認証プロバイダーを取得
/link-socialPOSTOAuth プロバイダーをアカウントに接続
/unlink-accountPOSTプロバイダーの接続を解除
ソーシャル OAuth エンドポイント
エンドポイントメソッド説明
/sign-in/socialPOSTOAuth フローを開始(プロバイダーは本文で指定)
/callback/:providerGETOAuth コールバックハンドラー(例:/callback/google)
/get-access-tokenGETプロバイダーのアクセストークンを取得

OAuth フローの例:

// Client initiates
await authClient.signIn.social({
  provider: "google",
  callbackURL: "/dashboard",
});

// better-auth handles redirect to Google
// Google redirects back to /api/auth/callback/google
// better-auth creates session automatically

プラグインのエンドポイント
二要素認証(2FA プラグイン)
import { twoFactor } from "better-auth/plugins";
エンドポイントメソッド説明
/two-factor/enablePOSTユーザーの 2FA を有効化
/two-factor/disablePOST2FA を無効化
/two-factor/get-totp-uriGET認証アプリ用の QR コード URI を取得
/two-factor/verify-totpPOST認証アプリの TOTP コードを検証
/two-factor/send-otpPOSTメールで OTP を送信
/two-factor/verify-otpPOSTメールの OTP を検証
/two-factor/generate-backup-codesPOSTリカバリーコードを作成
/two-factor/verify-backup-codePOSTバックアップコードを使ってログイン
/two-factor/view-backup-codesGET現在のバックアップコードを表示

📚 ドキュメント:https://www.better-auth.com/docs/plugins/2fa

組織プラグイン(マルチテナント SaaS)
import { organization } from "better-auth/plugins";

組織(10 エンドポイント):

エンドポイントメソッド説明
/organization/createPOST組織を作成
/organization/listGETユーザーの組織一覧を取得
/organization/get-fullGET組織の詳細情報を取得
/organization/updatePUT組織を変更
/organization/deleteDELETE組織を削除
/organization/check-slugGETスラッグが利用可能か確認
/organization/set-activePOSTアクティブな組織コンテキストを設定

メンバー(8 エンドポイント):

エンドポイントメソッド説明
/organization/list-membersGET組織のメンバーを取得
/organization/add-memberPOSTメンバーを直接追加
/organization/remove-memberDELETEメンバーを削除
/organization/update-member-rolePUTメンバーのロールを変更
/organization/get-active-memberGET現在のメンバー情報を取得
/organization/leavePOST組織から脱退

招待(7 エンドポイント):

エンドポイントメソッド説明
/organization/invite-memberPOST招待メールを送信
/organization/accept-invitationPOST招待を承諾
/organization/reject-invitationPOST招待を拒否
/organization/cancel-invitationPOST保留中の招待を取り消し
/organization/get-invitationGET招待の詳細を取得
/organization/list-invitationsGET組織の招待一覧を取得
/organization/list-user-invitationsGETユーザーの保留中の招待一覧を取得

チーム(8 エンドポイント):

エンドポイントメソッド説明
/organization/create-teamPOST組織内にチームを作成
/organization/list-teamsGET組織のチーム一覧を取得
/organization/update-teamPUTチームを変更
/organization/remove-teamDELETEチームを削除
/organization/set-active-teamPOSTアクティブなチームコンテキストを設定
/organization/list-team-membersGETチームのメンバー一覧を取得
/organization/add-team-memberPOSTチームにメンバーを追加
/organization/remove-team-memberDELETEチームメンバーを削除

権限とロール(6 エンドポイント):

エンドポイントメソッド説明
/organization/has-permissionPOSTユーザーが権限を持っているか確認
/organization/create-rolePOSTカスタムロールを作成
/organization/delete-roleDELETEカスタムロールを削除
/organization/list-rolesGETすべてのロールを一覧表示
/organization/get-roleGETロールの詳細を取得
/organization/update-rolePUTロールの権限を変更

📚 ドキュメント: https://www.better-auth.com/docs/plugins/organization

管理者プラグイン
import { admin } from "better-auth/plugins";

// v1.4.10 configuration options
admin({
  defaultRole: "user",
  adminRoles: ["admin"],
  adminUserIds: ["user_abc123"], // Always grant admin to specific users
  impersonationSessionDuration: 3600, // 1 hour (seconds)
  allowImpersonatingAdmins: false, // ⚠️ Default changed in v1.4.6
  defaultBanReason: "Violation of Terms of Service",
  bannedUserMessage: "Your account has been suspended",
})
エンドポイントメソッド説明
/admin/create-userPOST管理者としてユーザーを作成
/admin/list-usersGETすべてのユーザーを一覧表示(フィルター/ページネーション付き)
/admin/set-rolePOSTユーザーにロールを割り当て
/admin/set-user-passwordPOSTユーザーのパスワードを変更
/admin/update-userPUTユーザーの詳細を変更
/admin/remove-userDELETEユーザーアカウントを削除
/admin/ban-userPOSTユーザーアカウントを禁止(有効期限の指定は任意)
/admin/unban-userPOSTユーザーの禁止を解除
/admin/list-user-sessionsGETユーザーのアクティブなセッションを取得
/admin/revoke-user-sessionDELETE特定のユーザーセッションを終了
/admin/revoke-user-sessionsDELETEすべてのユーザーセッションを終了
/admin/impersonate-userPOSTユーザーのなりすましを開始
/admin/stop-impersonatingPOSTなりすましセッションを終了

⚠️ 破壊的変更(v1.4.6): allowImpersonatingAdmins のデフォルト値が false になりました。管理者が別の管理者になりすます必要がある場合は、明示的に true に設定してください。

権限付きカスタムロール(v1.4.10):

import { createAccessControl } from "better-auth/plugins/access";

// Define resources and permissions
const ac = createAccessControl({
  user: ["create", "read", "update", "delete", "ban", "impersonate"],
  project: ["create", "read", "update", "delete", "share"],
} as const);

// Create custom roles
const supportRole = ac.newRole({
  user: ["read", "ban"],      // Can view and ban users
  project: ["read"],          // Can view projects
});

const managerRole = ac.newRole({
  user: ["read", "update"],
  project: ["create", "read", "update", "delete"],
});

// Use in plugin
admin({
  ac,
  roles: {
    support: supportRole,
    manager: managerRole,
  },
})

📚 ドキュメント: https://www.better-auth.com/docs/plugins/admin

その他のプラグインエンドポイント

Passkey プラグイン(5 エンドポイント) - ドキュメント:

  • /passkey/add、/sign-in/passkey、/passkey/list、/passkey/delete、/passkey/update

Magic Link プラグイン(2 エンドポイント) - ドキュメント:

  • /sign-in/magic-link、/magic-link/verify

Username プラグイン(2 エンドポイント) - ドキュメント:

  • /sign-in/username、/username/is-available

電話番号プラグイン(5 エンドポイント) - ドキュメント:

  • /sign-in/phone-number、/phone-number/send-otp、/phone-number/verify、/phone-number/request-password-reset、/phone-number/reset-password

Email OTP プラグイン(6 エンドポイント) - ドキュメント:

  • /email-otp/send-verification-otp、/email-otp/check-verification-otp、/sign-in/email-otp、/email-otp/verify-email、/forget-password/email-otp、/email-otp/reset-password

匿名プラグイン(1 エンドポイント) - ドキュメント:

  • /sign-in/anonymous

JWT プラグイン(2 エンドポイント) - ドキュメント:

  • /token(JWT を取得)、/jwks(検証用の公開鍵)

OpenAPI プラグイン(2 エンドポイント) - ドキュメント:

  • /reference(Scalar UI を使用したインタラクティブな API ドキュメント)
  • /generate-openapi-schema(OpenAPI 仕様を JSON 形式で取得)

サーバーサイド API メソッド(auth.api.*)

すべての HTTP エンドポイントには、対応するサーバーサイドメソッドがあります。次の場合に使用します。

  • サーバーサイドミドルウェア(ルートの保護)
  • バックグラウンドジョブ(ユーザーの整理、通知)
  • 管理操作(ユーザーの一括管理)
  • カスタム認証フロー(プログラムによるセッションの作成)
コア API メソッド
// Authentication
await auth.api.signUpEmail({
  body: { email, password, name },
  headers: request.headers,
});

await auth.api.signInEmail({
  body: { email, password, rememberMe: true },
  headers: request.headers,
});

await auth.api.signOut({ headers: request.headers });

// Session Management
const session = await auth.api.getSession({ headers: request.headers });

await auth.api.listSessions({ headers: request.headers });

await auth.api.revokeSession({
  body: { token: "session_token_here" },
  headers: request.headers,
});

// User Management
await auth.api.updateUser({
  body: { name: "New Name", image: "https://..." },
  headers: request.headers,
});

await auth.api.changeEmail({
  body: { newEmail: "[email protected]" },
  headers: request.headers,
});

await auth.api.deleteUser({
  body: { password: "current_password" },
  headers: request.headers,
});

// Account Linking
await auth.api.linkSocialAccount({
  body: { provider: "google" },
  headers: request.headers,
});

await auth.api.unlinkAccount({
  body: { providerId: "google", accountId: "google_123" },
  headers: request.headers,
});
プラグイン API メソッド

2FA プラグイン:

// Enable 2FA
const { totpUri, backupCodes } = await auth.api.enableTwoFactor({
  body: { issuer: "MyApp" },
  headers: request.headers,
});

// Verify TOTP code
await auth.api.verifyTOTP({
  body: { code: "123456", trustDevice: true },
  headers: request.headers,
});

// Generate backup codes
const { backupCodes } = await auth.api.generateBackupCodes({
  headers: request.headers,
});

Organization プラグイン:

// Create organization
const org = await auth.api.createOrganization({
  body: { name: "Acme Corp", slug: "acme" },
  headers: request.headers,
});

// Add member
await auth.api.addMember({
  body: {
    userId: "user_123",
    role: "admin",
    organizationId: org.id,
  },
  headers: request.headers,
});

// Check permissions
const hasPermission = await auth.api.hasPermission({
  body: {
    organizationId: org.id,
    permission: "users:delete",
  },
  headers: request.headers,
});

Admin プラグイン:

// List users with pagination
const users = await auth.api.listUsers({
  query: {
    search: "john",
    limit: 10,
    offset: 0,
    sortBy: "createdAt",
    sortOrder: "desc",
  },
  headers: request.headers,
});

// Ban user
await auth.api.banUser({
  body: {
    userId: "user_123",
    reason: "Violation of ToS",
    expiresAt: new Date("2025-12-31"),
  },
  headers: request.headers,
});

// Impersonate user (for admin support)
const impersonationSession = await auth.api.impersonateUser({
  body: {
    userId: "user_123",
    expiresIn: 3600, // 1 hour
  },
  headers: request.headers,
});

用途に応じた使い分け
用途HTTP エンドポイントを使用auth.api.* メソッドを使用
クライアントサイド認証✅ はい❌ いいえ
サーバーミドルウェア❌ いいえ✅ はい
バックグラウンドジョブ❌ いいえ✅ はい
管理ダッシュボード✅ はい(クライアントから)✅ はい(サーバーから)
カスタム認証フロー❌ いいえ✅ はい
モバイルアプリ✅ はい❌ いいえ
API ルート✅ はい(ハンドラーにプロキシ)✅ はい(直接呼び出し)

例:保護されたルートのミドルウェア

import { Hono } from "hono";
import { createAuth } from "./auth";
import { createDatabase } from "./db";

const app = new Hono<{ Bindings: Env }>();

// Middleware using server-side API
app.use("/api/protected/*", async (c, next) => {
  const db = createDatabase(c.env.DB);
  const auth = createAuth(db, c.env);

  // Use server-side method
  const session = await auth.api.getSession({
    headers: c.req.raw.headers,
  });

  if (!session) {
    return c.json({ error: "Unauthorized" }, 401);
  }

  // Attach to context
  c.set("user", session.user);
  c.set("session", session.session);

  await next();
});

// Protected route
app.get("/api/protected/profile", async (c) => {
  const user = c.get("user");
  return c.json({ user });
});

利用可能なエンドポイントの確認

OpenAPI プラグインを使って、設定内のすべてのエンドポイントを確認できます。

import { betterAuth } from "better-auth";
import { openAPI } from "better-auth/plugins";

export const auth = betterAuth({
  database: /* ... */,
  plugins: [
    openAPI(), // Adds /api/auth/reference endpoint
  ],
});

インタラクティブなドキュメント:http://localhost:8787/api/auth/reference にアクセスしてください。

次の内容を含む Scalar UI が表示されます。

  • ✅ 機能ごとに分類された利用可能なすべてのエンドポイント
  • ✅ 型付きのリクエスト/レスポンススキーマ
  • ✅ 試用機能(ブラウザーでエンドポイントをテスト)
  • ✅ 認証要件
  • ✅ 複数の言語でのコード例

プログラムからのアクセス:

const schema = await auth.api.generateOpenAPISchema();
console.log(JSON.stringify(schema, null, 2));
// Returns full OpenAPI 3.0 spec

定量化した時間の削減

ゼロから構築する場合(手動で実装):

  • コア認証エンドポイント(サインアップ、サインイン、OAuth、セッション):40 時間
  • メール確認とパスワードリセット:10 時間
  • 2FA システム(TOTP、バックアップコード、メール OTP):20 時間
  • Organization(チーム、招待、RBAC):60 時間
  • 管理パネル(ユーザー管理、なりすまし):30 時間
  • テストとデバッグ:50 時間
  • セキュリティ強化:20 時間

手動作業の合計:約 220 時間(フルタイムで 5.5 週間)

better-auth を使用する場合:

  • 初期設定:2〜4 時間
  • カスタマイズとスタイリング:2〜4 時間

better-auth を使った場合の合計:4〜8 時間

削減率:開発時間を約 97%削減


主なポイント

better-auth は、以下をカバーする 本番環境対応のエンドポイントを 80 個以上提供します。

  • ✅ コア認証(20 個のエンドポイント)
  • ✅ 2FA とパスワードレス認証(15 個のエンドポイント)
  • ✅ 組織とチーム(35 個のエンドポイント)
  • ✅ 管理者とユーザーの管理(15 個のエンドポイント)
  • ✅ ソーシャル OAuth(コールバックを自動設定)
  • ✅ OpenAPI ドキュメント(インタラクティブ UI)

エンドポイントのコードを書く必要はありません。 機能を設定して、auth.handler() を呼び出すだけです。


既知の問題と解決策

問題 1:「d1Adapter is not exported」エラー

問題:コードに import { d1Adapter } from 'better-auth/adapters/d1' とありますが、これは存在しません。

症状:エクスポートが見つからないという TypeScript エラーまたは実行時エラーが発生します。

解決策:代わりに Drizzle または Kysely を使用します。

// ❌ WRONG - This doesn't exist
import { d1Adapter } from 'better-auth/adapters/d1'
database: d1Adapter(env.DB)

// ✅ CORRECT - Use Drizzle
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { drizzle } from 'drizzle-orm/d1'
const db = drizzle(env.DB, { schema })
database: drizzleAdapter(db, { provider: "sqlite" })

// ✅ CORRECT - Use Kysely
import { Kysely } from 'kysely'
import { D1Dialect } from 'kysely-d1'
database: {
  db: new Kysely({ dialect: new D1Dialect({ database: env.DB }) }),
  type: "sqlite"
}

出典:better-auth と D1 を使用している 4 つの本番環境リポジトリで確認済み


問題 2:スキーマ生成に失敗する

問題:npx better-auth migrate を実行しても、D1 と互換性のあるスキーマが作成されません。

症状:マイグレーション SQL の構文が正しくないか、D1 で動作しません。

解決策:Drizzle Kit を使用してマイグレーションを生成します。

# Generate migration from Drizzle schema
npx drizzle-kit generate

# Apply to D1
wrangler d1 migrations apply my-app-db --remote

理由:Drizzle Kit は D1 で動作する、SQLite 互換の SQL を生成します。


問題 3:「CamelCase」と「snake_case」のカラム不一致

問題:データベースには email_verified がありますが、better-auth は emailVerified を想定しています。

症状:セッションの読み取りに失敗し、ユーザーデータのフィールドが欠落します。

⚠️ 重要(v1.4.10 以降):Kysely の CamelCasePlugin を使用すると、better-auth アダプターでの結合解析が壊れます。このプラグインは _joined_user_user_id のような結合キーを _joinedUserUserId に変換するため、セッションクエリでユーザーデータが null になります。

Drizzle の解決策:最初から camelCase でスキーマを定義します(例を参照)。

CamelCasePlugin を使用する Kysely の解決策:better-auth 用に、CamelCasePlugin を使用しない別の Kysely インスタンスを使います。

// DB for better-auth (no CamelCasePlugin)
const authDb = new Kysely({
  dialect: new D1Dialect({ database: env.DB }),
})

// DB for app queries (with CamelCasePlugin)
const appDb = new Kysely({
  dialect: new D1Dialect({ database: env.DB }),
  plugins: [new CamelCasePlugin()],
})

export const auth = betterAuth({
  database: { db: authDb, type: "sqlite" },
})

出典:GitHub Issue #7136


問題 4:D1 の結果整合性

問題:書き込み直後のセッション読み取りで、古いデータが返されます。

症状:ユーザーがログインしたのに、次のリクエストで getSession() が null を返します。

解決策:セッションストレージに Cloudflare KV を使用します(強い整合性)。

import { betterAuth } from "better-auth";

export function createAuth(db: Database, env: Env) {
  return betterAuth({
    database: drizzleAdapter(db, { provider: "sqlite" }),
    session: {
      storage: {
        get: async (sessionId) => {
          const session = await env.SESSIONS_KV.get(sessionId);
          return session ? JSON.parse(session) : null;
        },
        set: async (sessionId, session, ttl) => {
          await env.SESSIONS_KV.put(sessionId, JSON.stringify(session), {
            expirationTtl: ttl,
          });
        },
        delete: async (sessionId) => {
          await env.SESSIONS_KV.delete(sessionId);
        },
      },
    },
  });
}

wrangler.toml に追加します:

[[kv_namespaces]]
binding = "SESSIONS_KV"
id = "your-kv-namespace-id"

問題 5:SPA アプリケーションでの CORS エラー

問題:認証 API とフロントエンドのオリジンが異なる場合に、CORS エラーが発生します。

症状:ブラウザーのコンソールに Access-Control-Allow-Origin エラーが表示されます。

解決策:Worker で CORS ヘッダーを設定し、trustedOrigins が一致することを確認します。

import { cors } from "hono/cors";

// CRITICAL: Both must match frontend origin exactly
app.use(
  "/api/auth/*",
  cors({
    origin: "http://localhost:5173", // Frontend URL (no trailing slash)
    credentials: true, // Allow cookies
    allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
  })
);

// And in better-auth config
export const auth = betterAuth({
  trustedOrigins: ["http://localhost:5173"], // Same as CORS origin
  // ...
});

よくある間違い:

  • オリジン URL の入力ミス(末尾のスラッシュ、http と https の違い、ポート番号の誤り)
  • CORS 設定と trustedOrigins のオリジンが一致していない
  • CORS ミドルウェアが認証ルートの後に登録されている(前に登録する必要があります)

出典:GitHub Issue #7434


問題 6:OAuth リダイレクト URI の不一致

問題:「redirect_uri_mismatch」エラーが発生し、ソーシャルサインインに失敗します。

症状:ユーザーの同意後に Google/GitHub OAuth がエラーを返します。

解決策:OAuth プロバイダーの設定で、完全に一致していることを確認します。

Provider setting: https://yourdomain.com/api/auth/callback/google
better-auth URL:  https://yourdomain.com/api/auth/callback/google

❌ Wrong: http vs https, trailing slash, subdomain mismatch
✅ Right: Exact character-for-character match

better-auth のコールバック URL を確認します:

// It's always: {baseURL}/api/auth/callback/{provider}
const callbackURL = `${env.BETTER_AUTH_URL}/api/auth/callback/google`;
console.log("Configure this URL in Google Console:", callbackURL);

問題 7:依存関係の不足

問題:パッケージが見つからないという TypeScript エラーまたは実行時エラーが発生します。

症状:Cannot find module 'drizzle-orm' などのエラーが表示されます。

解決策:必要なパッケージをすべてインストールします。

Drizzle を使用する場合:

npm install better-auth drizzle-orm drizzle-kit @cloudflare/workers-types

Kysely を使用する場合:

npm install better-auth kysely kysely-d1 @cloudflare/workers-types

問題 8:メール確認メールが送信されない

問題:メール確認リンクが届きません。

症状:ユーザーが登録しても、メールが届きません。

解決策:sendVerificationEmail ハンドラーを実装します。

export const auth = betterAuth({
  database: /* ... */,
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
  },
  emailVerification: {
    sendVerificationEmail: async ({ user, url }) => {
      // Use your email service (SendGrid, Resend, etc.)
      await sendEmail({
        to: user.email,
        subject: "Verify your email",
        html: `
          <p>Click the link below to verify your email:</p>
          <a href="${url}">Verify Email</a>
        `,
      });
    },
    sendOnSignUp: true,
    autoSignInAfterVerification: true,
    expiresIn: 3600, // 1 hour
  },
});

Cloudflare の場合:Cloudflare Email Routing または外部サービス(Resend、SendGrid)を使用します。


問題 9:セッションの有効期限が短すぎる

問題:セッションの有効期限が予期せず切れる、または切れません。

症状:ユーザーが予期せずログアウトされたり、ログアウト後もセッションが継続したりします。

解決策:セッションの有効期限を設定します。

export const auth = betterAuth({
  database: /* ... */,
  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7 days (in seconds)
    updateAge: 60 * 60 * 24, // Update session every 24 hours
  },
});

Issue 10:ソーシャルプロバイダーでユーザーデータが不足する

問題:ソーシャルサインインは成功しますが、ユーザーデータ(名前、アバター)が不足しています。

症状:Google/GitHub でサインインした後、session.user.name が null になります。

解決策:追加のスコープをリクエストします。

socialProviders: {
  google: {
    clientId: env.GOOGLE_CLIENT_ID,
    clientSecret: env.GOOGLE_CLIENT_SECRET,
    scope: ["openid", "email", "profile"], // Include 'profile' for name/image
  },
  github: {
    clientId: env.GITHUB_CLIENT_ID,
    clientSecret: env.GITHUB_CLIENT_SECRET,
    scope: ["user:email", "read:user"], // 'read:user' for full profile
  },
}

Issue 11:Drizzle スキーマで TypeScript エラーが発生する

問題:TypeScript がスキーマの型についてエラーを表示します。

症状:Type 'DrizzleD1Database' is not assignable to...

解決策:データベースから適切な型をエクスポートします。

// src/db/index.ts
import { drizzle, type DrizzleD1Database } from "drizzle-orm/d1";
import * as schema from "./schema";

export type Database = DrizzleD1Database<typeof schema>;

export function createDatabase(d1: D1Database): Database {
  return drizzle(d1, { schema });
}

Issue 12:Wrangler の開発モードが動作しない

問題:データベースエラーにより wrangler dev が失敗します。

症状:ローカル開発中に「Database not found」またはマイグレーションエラーが発生します。

解決策:まずローカルでマイグレーションを適用します。

# Apply migrations to local D1
wrangler d1 migrations apply my-app-db --local

# Then run dev server
wrangler dev

Issue 13:ユーザーデータの更新が UI に反映されない(TanStack Query 使用時)

問題:ユーザーデータ(アバター、名前など)を更新した後、queryClient.invalidateQueries() を呼び出しても、変更が useSession() に反映されません。

症状:更新が成功した後も、アバター画像やユーザープロフィールのデータが古いまま表示されます。TanStack Query のキャッシュには更新後のデータが表示されますが、better-auth のセッションには古い値が表示されます。

根本原因:better-auth はセッション状態の管理に TanStack Query ではなく、nanostores を使用しています。queryClient.invalidateQueries() を呼び出しても、無効化されるのは React Query のキャッシュのみで、better-auth の nanostore は無効化されません。

解決策:ユーザーデータの更新後、nanostore に手動で通知します。

// Update user data
const { data, error } = await authClient.updateUser({
  image: newAvatarUrl,
  name: newName
})

if (!error) {
  // Manually invalidate better-auth session state
  authClient.$store.notify('$sessionSignal')

  // Optional: Also invalidate React Query if using it for other data
  queryClient.invalidateQueries({ queryKey: ['user-profile'] })
}

使用するケース:

  • better-auth と TanStack Query を併用する場合
  • ユーザープロフィールのフィールド(名前、画像、メールアドレス)を更新する場合
  • クライアント側でセッションのユーザーデータを変更する操作全般

代替方法:useSession() の refetch() を呼び出す方法もありますが、$store.notify() のほうが直接的です。

const { data: session, refetch } = authClient.useSession()
// After update
await refetch()

注:$store はドキュメントに記載されていない内部 API です。この方法は本番環境で検証済みですが、今後の better-auth のバージョンで変更される可能性があります。

出典:コミュニティで見つかった方法で、本番環境での使用を確認済み


Issue 14:apiKey テーブルのスキーマが D1 と一致しない

問題:D1 を使用すると、better-auth CLI(npx @better-auth/cli generate)が「Failed to initialize database adapter」で失敗します。

症状:CLI が D1 に接続してスキーマを調査できません。CLI 経由でマイグレーションを実行することもできません。

根本原因:CLI は直接の SQLite 接続を想定していますが、D1 には Cloudflare のバインディング API が必要です。

解決策:CLI を使用せず、ドキュメントに記載された apiKey スキーマを使ってマイグレーションを手動で作成します。

CREATE TABLE api_key (
  id TEXT PRIMARY KEY NOT NULL,
  user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
  name TEXT,
  start TEXT,
  prefix TEXT,
  key TEXT NOT NULL,
  enabled INTEGER DEFAULT 1,
  rate_limit_enabled INTEGER,
  rate_limit_time_window INTEGER,
  rate_limit_max INTEGER,
  request_count INTEGER DEFAULT 0,
  last_request INTEGER,
  remaining INTEGER,
  refill_interval INTEGER,
  refill_amount INTEGER,
  last_refill_at INTEGER,
  expires_at INTEGER,
  permissions TEXT,
  metadata TEXT,
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL
);

重要なポイント:

  • テーブルには正確に 21 個のカラムがあります(better-auth v1.4 以降)
  • カラム名には snake_case を使用します(例:rateLimitTimeWindow ではなく rate_limit_time_window)
  • D1 は ALTER TABLE DROP COLUMN をサポートしていません。スキーマにずれが生じた場合は、新規マイグレーションのパターン(テーブルを削除して再作成)を使用します
  • Drizzle アダプターの設定では、テーブル名のマッピングに apikey(小文字)を使用します

D1 の新規マイグレーションパターン:

-- Drop in reverse dependency order
DROP TABLE IF EXISTS api_key;
DROP TABLE IF EXISTS session;
-- ... other tables

-- Recreate with clean schema
CREATE TABLE api_key (...);

出典:D1 と better-auth apiKey プラグインを使った本番環境でのデバッグ


Issue 15:Admin プラグインには DB 内のロールが必要(2 段階の認証)

問題:ミドルウェアを通過しているにもかかわらず、listUsers などの Admin プラグインのメソッドが「You are not allowed to list users」で失敗します。

症状:カスタムの requireAdmin ミドルウェア(ADMIN_EMAILS 環境変数をチェック)が通過しても、auth.api.listUsers() は 403 を返します。

根本原因:better-auth の Admin プラグインには、2 つの認可レイヤーがあります。

  1. ユーザー側のミドルウェア - カスタムチェック(例:ADMIN_EMAILS)
  2. better-auth 内部 - データベースの user.role === 'admin' をチェック

Admin プラグインのメソッドを動作させるには、両方のチェックを通過する必要があります。

解決策:データベースでユーザーのロールを「admin」に設定します。

-- Fix for existing users
UPDATE user SET role = 'admin' WHERE email = '[email protected]';

または、初期設定後に Admin UI/API を使ってロールを設定します。

理由:Admin プラグインの listUsers、banUser、impersonateUser などはすべて、ユーザー独自のミドルウェアのロジックではなく、データベース内の user.role をチェックします。

出典:本番環境でのデバッグ。誤解を招くエラーメッセージをきっかけに、wrangler tail で根本原因を特定


Issue 16:Organization/Team の updated_at は NULL を許可する必要があります

問題:API が「slug はすでに存在します」と返すにもかかわらず、Organization の作成が SQL 制約エラーで失敗します。

症状:

  • エラーメッセージに「この slug を持つ Organization はすでに存在します」と表示されます
  • データベースのテーブルは実際には空です
  • wrangler tail に次のように表示されます:Failed query: insert into "organization" ... values (?, ?, ?, null, null, ?, null)

根本原因:better-auth は作成時に updated_at に null を挿入します(更新時のみ値を設定します)。スキーマに NOT NULL 制約がある場合、挿入に失敗します。

解決策:スキーマとマイグレーションの両方で updated_at を NULL 許可にします:

// Drizzle schema - CORRECT
export const organization = sqliteTable('organization', {
  // ...
  updatedAt: integer('updated_at', { mode: 'timestamp' }), // No .notNull()
});

export const team = sqliteTable('team', {
  // ...
  updatedAt: integer('updated_at', { mode: 'timestamp' }), // No .notNull()
});
-- Migration - CORRECT
CREATE TABLE organization (
  -- ...
  updated_at INTEGER  -- No NOT NULL
);

該当箇所:organization と team のテーブル(他のプラグインのテーブルも該当する可能性があります)

出典:本番環境でのデバッグ。wrangler tail により、「slug はすでに存在します」という誤解を招くメッセージの裏にある実際の SQL エラーが判明


Issue 17:API レスポンスの二重ネスト(listMembers など)

問題:カスタム API エンドポイントが { members: { members: [...], total: N } } のように、データを二重にネストして返します。

症状:データが存在するにもかかわらず、UI の件数が「undefined」と表示され、リストが空になります。

根本原因:listMembers のような better-auth のメソッドは { members: [...], total: N } を返します。c.json({ members: result }) でラップすると、二重にネストされます。

解決策:better-auth のレスポンスから配列を取り出します:

// ❌ WRONG - Double nesting
const result = await auth.api.listMembers({ ... });
return c.json({ members: result });
// Returns: { members: { members: [...], total: N } }

// ✅ CORRECT - Extract array
const result = await auth.api.listMembers({ ... });
const members = result?.members || [];
return c.json({ members });
// Returns: { members: [...] }

影響を受けるメソッド(配列ではなくオブジェクトを返します):

  • listMembers → { members: [...], total: N }
  • listUsers → { users: [...], total: N, limit: N }
  • listOrganizations → { organizations: [...] }(構造を確認してください)
  • listInvitations → { invitations: [...] }

パターン:API レスポンスでラップする前に、better-auth メソッドの戻り値の型を必ず確認してください。

出典:本番環境でのデバッグ。UI の件数が「undefined」と表示され、API を調査したところネストの問題が判明


Issue 18:Expo クライアントで fromJSONSchema のクラッシュ(v1.4.16)

問題:v1.4.16 で @better-auth/expo/client から expoClient をインポートすると、TypeError: Cannot read property 'fromJSONSchema' of undefined が発生してクラッシュします。

症状:React Native/Expo アプリで expoClient をインポートすると、すぐに実行時クラッシュが発生します。

根本原因:PR #6933(Expo の Cookie ベースの OAuth state 修正)の後に導入されたリグレッションです。f4a9f15 以降の3つのコミットのうち、いずれかがビルドを壊しました。

解決策:

  • 一時的な対応:リグレッション発生前のコミット f4a9f15 の継続的ビルドを使用します
  • 恒久的な対応:修正を待ちます(2026-01-20 時点で issue #7491 は未解決)
// Crashes on v1.4.16
import { expoClient } from '@better-auth/expo/client'

// Workaround: Use continuous build at f4a9f15
// Or wait for fix in next release

出典:GitHub Issue #7491


Issue 19:additionalFields の string[] が JSON 文字列を返す

問題:v1.4.12 以降、Drizzle で直接クエリした場合、type: 'string[]' の additionalFields はネイティブ配列ではなく、文字列化された配列('["a","b"]')を返します。

症状:user.notificationTokens が配列ではなく文字列になります。配列を想定したコードが動作しなくなります。

根本原因:Drizzle アダプターでは、string[] フィールドは配列を想定する mode: 'json' を使って保存されます。しかし、better-auth v1.4.4 以降は Drizzle に文字列を渡すため、二重に文字列化されます。Drizzle で直接クエリすると値は文字列ですが、better-auth の internalAdapter を使用すると、トランスフォーマーによって正しく配列が返されます。

解決策:

  1. Drizzle に直接クエリする代わりに、better-auth の internalAdapter を使用します(トランスフォーマーがあります)
  2. string[] フィールド用に Drizzle スキーマを .jsonb() に変更します
  3. 修正されるまで JSON 文字列を手動で解析します
// Config
additionalFields: {
  notificationTokens: {
    type: 'string[]',
    required: true,
    input: true,
  },
}

// Create user
notificationTokens: ['token1', 'token2']

// Result in DB (when querying via Drizzle directly)
// '["token1","token2"]' (string, not array)

出典:GitHub Issue #7440


Issue 20: additionalFields の "returned" プロパティが入力をブロックする

問題: additionalFields に returned: false を設定すると、input: true であっても、API 経由でフィールドを保存できなくなります。

症状: API エンドポイント経由で作成または更新しても、フィールドがデータベースに保存されません。

根本原因: returned: false プロパティは、意図されている読み取りだけでなく、読み取りと書き込みの両方の操作をブロックします。input: true プロパティは、書き込みアクセスを独立して制御する必要があります。

解決策:

  • API からの書き込みアクセスが必要な場合は、returned: false を使用しないでください
  • 代わりにサーバー側のメソッド(auth.api.*)経由で書き込んでください
// Organization plugin config
additionalFields: {
  secretField: {
    type: 'string',
    required: true,
    input: true,      // Should allow API writes
    returned: false,  // Should only block reads, but blocks writes too
  },
}

// API request to create organization
// secretField is never saved to database

出典: GitHub Issue #7489


Issue 21: freshAge はアクティビティではなく作成時刻に基づく

問題: session.freshAge が確認するのは、最近のアクティビティではなく作成からの経過時間です。アクティブなセッションでも、頻繁に使用されている場合であっても、freshAge が経過すると「新鮮ではない」と判定されます。

症状: 「新鮮なセッションが必要」なエンドポイントが、有効でアクティブなセッションを拒否します。

発生する理由: freshSessionMiddleware は Date.now() - (session.updatedAt || session.createdAt) を確認しますが、updatedAt が更新されるのは、updateAge に基づいてセッションが更新されたときだけです。updateAge > freshAge の場合、updatedAt が更新される前にセッションが「新鮮ではない」状態になります。

解決策:

  1. updateAge <= freshAge に設定し、期限切れ前に新鮮さが更新されるようにします
  2. 長期間有効なセッションでは、「新鮮なセッションが必要」という制限を避けてください
  3. 仕様として受け入れてください:freshAge は厳密に作成からの経過時間です(メンテナー確認済み)
// Config
session: {
  expiresIn: 60 * 60 * 24 * 7,    // 7 days
  freshAge: 60 * 60 * 24,          // 24 hours
  updateAge: 60 * 60 * 24 * 3,     // 3 days (> freshAge!) ⚠️ PROBLEM

  // CORRECT - updateAge <= freshAge
  updateAge: 60 * 60 * 12,         // 12 hours (< freshAge)
}

// Timeline with bad config:
// T+0h: User signs in (createdAt = now)
// T+12h: User makes requests (session active, still fresh)
// T+25h: User makes request (session active, BUT NOT FRESH - freshAge elapsed)
// Result: "Fresh session required" endpoints reject active session

出典: GitHub Issue #7472


Issue 22: OAuth トークンエンドポイントがラップされた JSON を返す

問題: OAuth 2.1 および OIDC のトークンエンドポイントは、仕様に準拠したトップレベルの JSON ではなく、{ "response": { ...tokens... } } を返します。OAuth クライアントはルートで { "access_token": "...", "token_type": "bearer" } が返されることを想定しています。

症状: OAuth クライアントが Bearer undefined または invalid_token で失敗します。

根本原因: エンドポイントのパイプラインは内部利用向けに { response, headers, status } を返しますが、HTTP リクエストではこれがそのままシリアライズされます。このため、OAuth/OIDC の仕様要件を満たせません。

解決策:

  • 一時的な対処: クライアント側で .response フィールドを手動でアンラップします
  • 恒久的な対処: 修正を待ちます(issue #7355 は対応中で、コントリビューションを受け付けています)
// Expected (spec-compliant)
{ "access_token": "...", "token_type": "bearer", "expires_in": 3600 }

// Actual (wrapped)
{ "response": { "access_token": "...", "token_type": "bearer", "expires_in": 3600 } }

// Result: OAuth clients fail to parse, send `Bearer undefined`

出典: GitHub Issue #7355


移行ガイド

Clerk からの移行

主な違い:

  • Clerk:サードパーティサービス → better-auth:セルフホスト可能
  • Clerk:プロプライエタリ → better-auth:オープンソース
  • Clerk:月額費用あり → better-auth:無料

移行手順:

  1. Clerk からユーザーデータをエクスポートします(CSV または API)
  2. better-auth のデータベースにインポートします:
    // migration script
    const clerkUsers = await fetchClerkUsers();
    
    for (const clerkUser of clerkUsers) {
      await db.insert(user).values({
        id: clerkUser.id,
        email: clerkUser.email,
        emailVerified: clerkUser.email_verified,
        name: clerkUser.first_name + " " + clerkUser.last_name,
        image: clerkUser.profile_image_url,
      });
    }
    
  3. Clerk SDK を better-auth クライアントに置き換えます:
    // Before (Clerk)
    import { useUser } from "@clerk/nextjs";
    const { user } = useUser();
    
    // After (better-auth)
    import { authClient } from "@/lib/auth-client";
    const { data: session } = authClient.useSession();
    const user = session?.user;
    
  4. セッション検証用にミドルウェアを更新します
  5. ソーシャルプロバイダーを設定します(同じ OAuth アプリを使用し、設定は異なります)

Auth.js (NextAuth) からの移行

主な違い:

  • Auth.js:機能が限定的 → better-auth:包括的(2FA、組織など)
  • Auth.js:コールバック中心 → better-auth:プラグインベース
  • Auth.js:セッション処理に一貫性がない → better-auth:一貫性がある

移行手順:

  1. データベーススキーマ:Auth.js と better-auth は類似したスキーマを使用しますが、カラム名が異なります
  2. 設定を置き換えます:
    // Before (Auth.js)
    import NextAuth from "next-auth";
    import GoogleProvider from "next-auth/providers/google";
    
    export default NextAuth({
      providers: [GoogleProvider({ /* ... */ })],
    });
    
    // After (better-auth)
    import { betterAuth } from "better-auth";
    
    export const auth = betterAuth({
      socialProviders: {
        google: { /* ... */ },
      },
    });
    
  3. クライアントフックを更新します:
    // Before
    import { useSession } from "next-auth/react";
    
    // After
    import { authClient } from "@/lib/auth-client";
    const { data: session } = authClient.useSession();
    

追加リソース

公式ドキュメント
コアコンセプト
認証方法
プラグインのドキュメント

コアプラグイン:

パスワードレスプラグイン:

高度なプラグイン:

フレームワーク統合
コミュニティとサポート
関連ドキュメント

本番環境の例

動作確認済みの D1 リポジトリ (すべて Drizzle または Kysely を使用):

  1. zpg6/better-auth-cloudflare - Drizzle + D1 (CLI を含む)
  2. zwily/example-react-router-cloudflare-d1-drizzle-better-auth - Drizzle + D1
  3. foxlau/react-router-v7-better-auth - Drizzle + D1
  4. matthewlynch/better-auth-react-router-cloudflare-d1 - Kysely + D1

直接 d1Adapter を使用しているものは ありません - すべて Drizzle/Kysely が必要です。


バージョン互換性

テスト済み:

破壊的変更:

  • v1.4.6: allowImpersonatingAdmins のデフォルトが false に
  • v1.4.0: ESM のみ (CommonJS なし)
  • v1.3.0: マルチチームのテーブル構造の変更

変更履歴を確認: https://github.com/better-auth/better-auth/releases


コミュニティリソース

Cloudflare 固有のガイド:


トークン効率:

  • スキルなし:~35,000 トークン(D1 アダプターのエラー、15 以上のプラグイン、レート制限、セッションキャッシュ、データベースフック、モバイル統合)
  • スキルあり:~8,000 トークン(エラー + パターン + すべてのプラグイン + API リファレンスに重点)
  • 削減率:~77%(~27,000 トークン)

防止したエラー:解決策を明記した 22 件の問題 主な価値:D1 アダプターの要件、nodejs_compat フラグ、OAuth 2.1 プロバイダー、Bearer/OneTap/SCIM/Anonymous プラグイン、レート制限、セッションキャッシュ、データベースフック、Expo 統合、80 以上のエンドポイントリファレンス、additionalFields のバグ、freshAge の動作、OAuth トークンのラッピング


最終確認日:2026-01-21 | スキルのバージョン:5.1.0 | 変更点:トレーニングデータの期限後の調査から新たに 5 件の問題を追加(Expo の fromJSONSchema クラッシュ、additionalFields の string[] バグ、additionalFields の返却プロパティのバグ、freshAge はアクティビティ基準ではない、OAuth トークンのラッピング)。Kysely CamelCasePlugin の JOIN 解析失敗について Issue #3 を拡充。Hono の CORS パターンについて Issue #5 を拡充。Cloudflare Workers の DB バインディングの制約に関する注記を追加。TanStack Start のセッションの null 許容パターンを追加。v1.4.16 に更新。

コメント