better-auth(ベターオース、表記はBetter Auth)は、TypeScriptで書かれたオープンソースの認証フレームワークです。メールとパスワード、Googleログイン、2要素認証、パスキーを自分のDBとサーバーの上で動かせ、2025年9月からはAuth.js(旧NextAuth.js)の保守も担っています。この記事は2026年10月20日公開のv1.7.12を前提に、Auth.jsやAuth0・Clerkとの違い、導入手順、Redisへのセッション保存、JWTの扱い、2FA、セキュリティの既定値を整理します。Redis連携・レート制限・JWTは手元でv1.7.12を本番モードで動かし、書き込まれるキーとステータスコードを確かめました。
まとめ:better-authの要点(v1.7.12時点)
- better-authはMITライセンスのTypeScript向け認証フレームワークで、本体は無料です。有料なのはダッシュボードや監査ログを提供する「Better Auth Infrastructure」(Starter $0、Pro $20/月)だけで、ユーザー数による課金はありません。
- Auth.jsとの主な違いは、2FA・パスキー・組織管理を公式プラグインで、レート制限を本体の組み込み機能で持つ点です。Auth.jsの保守は2025年9月22日からBetter Authチームが担い、新規はBetter Authが推奨されています。
- Redisは
secondaryStorageに公式パッケージ@better-auth/redis-storageを渡すだけで使えます。セッションはDBでなくRedisに保存され、レート制限のカウンタもRedisへ寄せられます。ロードバランサー配下でクライアントIPを特定できないと、同じ認証パスへのリクエストが共有カウンタに集約されます。trustedProxiesまたはプロキシが設定する信頼できる単一値のIPヘッダを指定します。 - 既定のセッションはJWTではなく、DB(またはRedis)に保存したトークンをCookieで照合する方式です。外部サービス向けのJWTはJWTプラグイン、Cookieを送れないアプリやTypeScript以外のクライアントにはBearerプラグインを足します。
- 2026年のセキュリティアドバイザリは10月23日までに27件(critical 3件)。1.4.0-beta.18以上1.7.7未満でMagic LinkとOAuthを併用する構成は、トークンの保存設定とOAuth stateの保存方式によって、他人としてログインされる脆弱性の対象になります。該当環境は関連パッケージとともに1.7.7以上へ更新します。
better-authの位置づけ:TypeScript向けのセルフホスト型認証基盤
Better Authの読み方・ライセンスと料金の内訳
読みは「ベターオース」です。npmのパッケージ名は better-auth、公式の表記は「Better Auth」。GitHubリポジトリ better-auth/better-auth は2024年5月作成で、スター数は30,431(2026年10月23日時点)とAuth.js(28,368)を上回っています。運営はBetter Auth Inc.で、2026年7月7日にVercelへの合流を発表しました。発表文はオープンソースでフレームワーク・プラットフォーム非依存を続けると明言しており、Vercel専用になったわけではありません。
公式サイトの料金ページは冒頭で「The Better Auth framework is free and open source」と書いており(Better Auth公式の料金ページ)、料金表はマネージドの「Better Auth Infrastructure」のものです。
| プラン | 月額 | 監査ログ | 不正検知 | 主な追加機能 |
|---|---|---|---|---|
| Starter | $0 | 1万件/月・保持1日 | 1,000件/月 | ダッシュボード1席 |
| Pro | $20 | 2万件/月(超過$0.0001/件) | 1万件/月(超過$0.001/件) | 席数無制限、SSO・ディレクトリ同期各1接続 |
| Enterprise | 個別見積 | 個別 | 個別 | ログドレイン、独自ダッシュボードドメイン |
Infrastructureを使わなければBetter Auth本体の利用料はかかりませんが、サーバー・DBのほか、利用するメール・SMS配信サービスや保守運用の費用は別途必要です。
Better Authが動くフレームワークと対応データベース
インストールガイドにはNext.js、Nuxt、SvelteKit、Hono、Cloudflare Workers、Express、Elysia、TanStack Start、Expoなどのハンドラ例があり、標準の Request/Response を扱えるサーバーならどれでも動きます。Better AuthにHonoやtRPCを組み合わせた雛形を一度に作るなら、Better-T-Stackの使い方と既定構成のCLIも選択肢です。
データベースは、内蔵のKysely経由でPostgreSQL・MySQL・SQLite・MS SQLに直接つなぐか、Prisma・Drizzle・MongoDBのアダプタを使います。Kyselyの方言に乗るNeon、PlanetScale、Cloudflare D1、Supabaseも使えます。v1.4(2025年11月)からはDBを渡さない構成も選べ、その場合はセッションを署名・暗号化したCookieだけで扱います。ただし公式が「most plugins will require a database」と書くとおり、2FAや組織管理を使うならDBは必須です。
主要プラグインと別パッケージで配る機能
本体に同梱のプラグインは better-auth/plugins から読み込みます。2FA(twoFactor)、ユーザー名、マジックリンク、メールOTP、組織、管理者、JWT、Bearer、ワンタップ、マルチセッションなどがここに入ります。依存が重い機能は別パッケージで、v1.7系のリリースノートには @better-auth/passkey・sso・scim・stripe・oauth-provider・mcp・api-key・expo・electron が並びます。パスキーを足すときに better-auth/plugins から探しても見つからないのはこのためです。
別パッケージは本体と同じ版番号で出るので、本体だけを上げると版がずれます。CLIの npx auth@latest upgrade は本体と @better-auth/* をCLIの版にそろえて更新します。
Auth.jsとの違いと移行の判断
2025年9月の保守移管とAuth.js利用者への推奨
2025年9月22日、Auth.jsの保守がBetter Authチームへ移りました。公式の移行ガイドは、Auth.jsが問題なく動いているなら急ぐ必要はなくセキュリティパッチは続けるとしたうえで、新規プロジェクトには「we strongly recommend using Better Auth」と書いています。
判断は単純です。これから作るならBetter Auth、Auth.js v4で安定稼働しているなら据え置き、v5ベータの上で2FAやレート制限を自作しているなら移行を検討します。Auth.js側の現状(v5がベータのまま、2026年7月の脆弱性修正版)はAuth.jsとNextAuthの違いとv5の現状で整理しています。
Better AuthとAuth.js v5の認証機能・セッション設計の比較
| 観点 | Better Auth 1.7 | Auth.js v5(beta) |
|---|---|---|
| メール+パスワード | 組み込み(emailAndPassword) |
Credentialsで自作 |
| 2FA・パスキー | 公式プラグイン | パスキーのみ実験的 |
| レート制限 | 組み込み(本番で既定有効) | なし |
| セッション保存 | DB・Redis・Cookieのみ | アダプタ無しはJWT、有りはDB(既定) |
| 組織(マルチテナント)管理 | 公式プラグイン | なし |
Auth.jsはOAuthログインの入口に強く、パスワード認証や2FAは利用者が組み立てます。Better Authはそこまでを設定で済ませる代わりに、テーブル構造がAuth.jsと異なります。
移行で変わるテーブルとフィールド
Better Auth公式のAuth.js移行ガイドの比較表から、データ移行で書き換えが必要な箇所を抜き出すと次のとおりです。
| テーブル | Auth.js | Better Auth |
|---|---|---|
| User | emailVerified(日時) |
emailVerified(真偽値・必須) |
| Session | sessionToken・expires |
token・expiresAt |
| Account | provider・refresh_token |
providerId・refreshToken |
| VerificationToken | 複合主キー・token |
Verification(id主キー・value) |
見落としやすいのはパスワードの置き場所です。Auth.jsではUserに直接持たせる実装が多いのに対し、Better AuthはAccountテーブルに providerId: "credential" と、移行先ユーザーのIDを設定した accountId を持つ行を作り、そこへ保存します。この行を作らずに移すと、パスワードでのログインがすべて失敗します。ハッシュの形式も確認が必要で、既定のscryptでは移行元のbcryptハッシュを検証できないため、既存ハッシュを使い続けるなら password.verify をbcrypt対応の関数に差し替えます。ルートも app/api/auth/[...nextauth] から [...all] に改名し、toNextJsHandler(auth) に差し替えます。
Auth0・Clerk・Cognito・Supabase Authとの違い:ユーザーの置き場所と課金単位
マネージド認証(IDaaS)との違いは2つです。ユーザー情報が提供者側にあるか自社DBにあるか、そして利用者数で課金されるかどうかです。Better Authはユーザーを自社DBのuserテーブルに置くため、注文や権限のテーブルと普通にJOINでき、提供者側との同期処理が要りません。
| サービス | 課金単位 | 無料枠 | ユーザーの保存先 |
|---|---|---|---|
| Better Auth | なし | 無制限 | 自社DB |
| Auth0 | MAU | 25,000 MAU(Free) | Auth0 |
| Clerk | MRU | アプリごと50,000 MRU(Hobby) | Clerk |
| Amazon Cognito | MAU | 10,000 MAU(Lite・Essentials) | Cognito |
| Supabase Auth | MAU | 50,000 MAU(Free) | SupabaseのPostgres |
数値は各社の料金ページ(Auth0・Clerk・Amazon Cognito・Supabase)によります。ClerkのMRUは登録から24時間以上たって再訪した利用者だけを数える方式です。CognitoのSAML・OIDCフェデレーションは料金tierに関係なく月50 MAUまで無料で、Plusには直接サインインの無料枠がありません。Supabaseの通常のMAU超過料金は、Pro・Teamに含まれる100,000 MAUを超えた分について1 MAUあたり$0.00325です。Auth0の課金が無料枠を超えたあとにどう増えるかはAuth0の料金とMAU課金の実額、ClerkはClerk認証の導入手順とMRU料金、CognitoはCognitoで認証基盤を実装する手順で扱っています。
無料枠の範囲で収まる規模なら、費用差より運用の差で決めます。IDaaSは認証基盤の脆弱性対応を提供者が負い、Better Authは自分で負います。会員数が数十万に届くB2Cや、ユーザー情報を国内の自社DBから出したくない案件ではBetter Authが有利です。認証基盤の選定から実装までを外部に任せたい場合は、一創の認証基盤・ID管理システム開発でも相談を受けています。
導入手順:インストールからハンドラのマウントまで
パッケージと環境変数、auth.tsの作成
PostgreSQLを使う例なら npm install better-auth pg を実行し、BETTER_AUTH_SECRET・BETTER_AUTH_URL・DATABASE_URL と、Googleログインを使う場合は GOOGLE_CLIENT_ID・GOOGLE_CLIENT_SECRET を設定します。シークレットは32文字以上の高エントロピー値が必要で、npx auth@latest secret か openssl rand -base64 32 で作れます。短い値や同じ文字の繰り返しでは、1.7.12でも起動時に「appears low-entropy」の警告が出ました。
# .env
BETTER_AUTH_SECRET=(npx auth@latest secret の出力)
BETTER_AUTH_URL=http://localhost:3000
DATABASE_URL=postgres://user:pass@localhost:5432/app
GOOGLE_CLIENT_ID=(Google Cloud Consoleで発行)
GOOGLE_CLIENT_SECRET=(同上)
// lib/auth.ts
import { betterAuth } from "better-auth";
import { Pool } from "pg";
export const auth = betterAuth({
database: new Pool({ connectionString: process.env.DATABASE_URL }),
emailAndPassword: { enabled: true },
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
},
},
});
auth.ts はCLIが探せるよう、プロジェクト直下か lib/・utils/(src/ 配下も可)に置き、auth という名前かdefaultでexportします。
スキーマ作成:npx auth migrateとgenerateの使い分け
CLIの実行にはNode.js 22.12以上が必要です。CLIは npx auth@latest で呼びます(旧 @better-auth/cli ではありません)。内蔵Kyselyで直接DBにつないでいるなら migrate がuser・session・account・verificationの4テーブルを作成します。PrismaやDrizzleを使う場合は generate でスキーマを出力し、ORM側のマイグレーションで適用します。migrate はKysely以外では動きません。プラグインを追加したときもテーブルや列が増えるので、そのたびにどちらかを実行し直します。設定の誤りは npx auth@latest check でまとめて点検できます。
Next.jsとExpressへのハンドラのマウント
Better Authのエンドポイントは /api/auth/* にまとめて生えます。Next.jsのApp Routerでは app/api/auth/[...all]/route.ts に export const { POST, GET } = toNextJsHandler(auth); と書くだけです。
// server.ts(Express 5。package.json に "type": "module")
import express from "express";
import { toNodeHandler, fromNodeHeaders } from "better-auth/node";
import { auth } from "./lib/auth";
const app = express();
app.all("/api/auth/*splat", toNodeHandler(auth)); // Express 4 は "/api/auth/*"
app.use(express.json()); // 必ずBetter Authの後に置く
app.get("/api/me", async (req, res) => {
const session = await auth.api.getSession({ headers: fromNodeHeaders(req.headers) });
res.json(session);
});
app.listen(3000);
Expressで詰まりやすい点は2つあります。express.json() を先に置くとリクエストボディが消費され、Better Authが読めなくなります。CommonJSには対応していないため、ESMで動かす必要があります。起動の確認は GET /api/auth/ok が {"ok":true} を返すかどうかで見られます。
authClientとauth.apiの使い分け
ブラウザから呼ぶ authClient はHTTPで /api/auth/* を叩き、auth.api はHTTPを通らずに関数として実行されます(公式のAPIページ)。
| 観点 | authClient | auth.api |
|---|---|---|
| 呼ぶ場所 | ブラウザ・React等 | サーバー(Route Handler等) |
| 引数 | 値をそのまま渡す | body・headers・query に分ける |
| Cookie | ブラウザが自動で付ける | headers を自分で渡す |
| 失敗時 | { data, error } を返す |
APIError を投げる |
| レート制限 | 対象 | 対象外 |
auth.api の戻り値は普通のオブジェクトで、Set-Cookieを取りたいときは returnHeaders: true、Responseごと欲しいときは asResponse: true を渡します。注意したいのはレート制限です。本番モードで誤ったパスワードを auth.api.signInEmail に6回続けて渡しても、429は1回も返りませんでした。Server Actionなどでログインを auth.api 経由にすると、総当たり対策が外れます。ブラウザからのログインは authClient で通します。
Redisでセッションを持つ:secondaryStorageの設定と実測
@better-auth/redis-storageの設定
Redis対応は、v1.5(2026年2月28日発表)で公式パッケージ @better-auth/redis-storage として本体から分かれました。内部でioredisを使い、本体と同じ版番号で出ています(2026年10月23日時点で1.7.12)。
npm install @better-auth/redis-storage ioredis
// lib/auth.ts
import { betterAuth } from "better-auth";
import { Pool } from "pg";
import { Redis } from "ioredis";
import { redisStorage } from "@better-auth/redis-storage";
const redis = new Redis(process.env.REDIS_URL!);
export const auth = betterAuth({
database: new Pool({ connectionString: process.env.DATABASE_URL }),
emailAndPassword: { enabled: true },
secondaryStorage: redisStorage({ client: redis, keyPrefix: "better-auth:" }),
// secondaryStorage があれば rateLimit.storage は既定で "secondary-storage" になる
});
keyPrefix は省略すると better-auth: です。同じRedisを別アプリと共有するなら、アプリごとに変えておきます。ValkeyはRedis互換なのでこのまま使えます(Valkey側の事情はValkeyとRedisの違いを参照)。
Redisに書き込まれるキーとTTL(v1.7.12で実測)
上の設定のDBをメモリアダプタに替え、Redisはローカルで起動したValkeyを使って確かめた結果です。メール登録1件の直後に、次の3キーが並びました。
| キー | 中身 | TTL |
|---|---|---|
better-auth:<セッショントークン> |
セッションとユーザー | 604,799秒 |
better-auth:active-sessions-<userId> |
ユーザーの有効セッション一覧 | 604,799秒 |
better-auth:<IP>|/sign-up/email |
レート制限カウンタ | 10秒 |
TTLの604,799秒は、既定のセッション有効期限7日(604,800秒)から1秒経過した値です。このときDBのsessionテーブルは0行でした。secondaryStorage を渡すと、セッションの保存先がDBからRedisへ切り替わります。正しいパスワードでサインインし直してからサインアウトすると、セッションの2キーはどちらも即座に消えました。
運用上の注意が2つあります。Redisを永続化なしで再起動すると全ユーザーがログアウトします。セッションの監査や集計をSQLでしたいなら、session: { storeSessionInDatabase: true } を足してDBにも書かせます。
レート制限カウンタをRedisへ寄せる理由
secondaryStorage を渡していない場合、レート制限の保存先はプロセスのメモリです。公式ドキュメントもサーバーレス環境には向かないと書いており、Vercelの関数やコンテナを複数台並べると、インスタンスごとに別々のカウンタを数えることになります。secondaryStorage を渡すと rateLimit.storage の既定値が "secondary-storage" になり、全台で1つのカウンタを共有します。DBに置きたいときだけ "database" を明示します。
実測では、同じIPから誤ったパスワードで /sign-in/email を連続で叩くと、3回目までは401、4回目で429が返り、X-Retry-After: 10 が付きました。/sign-in・/sign-up で始まるパスの既定上限「10秒に3回」が、Redis上のカウンタ better-auth:<IP>|/sign-in/email で効いています。なおレート制限は開発モードでは既定で無効なので、ローカルで試すときは rateLimit.enabled: true を明示します。
ロードバランサー配下でカウンタが全員共有になる問題とtrustedProxies
Redisへ寄せたあとに起きやすいのが、IPの取り違えです。Better AuthはIPを X-Forwarded-For から読みますが、1.7系はカンマ区切りの値(198.51.100.1, 10.0.0.7 のようにプロキシが追記した形)を信用しません。先頭の値は利用者が偽装できるからです(公式のレート制限ページ)。信用できないときの扱いが問題で、実装はIPを諦めて no-trusted-ip という1つのカウンタに全リクエストをまとめます。
v1.7.12で、別々の4人(198.51.100.1〜4)が1回ずつパスワードを間違え、プロキシ10.0.0.7がそれぞれのIPを追記した状況を再現しました。
| 設定 | 1〜3人目 | 4人目 | Redisのキー |
|---|---|---|---|
| 既定 | 401 | 429 | better-auth:no-trusted-ip|/sign-in/email 1本 |
trustedProxies: ["10.0.0.7"] |
401 | 401 | 利用者のIPごとに4本 |
既定のままでは、無関係な4人目が「試行回数が多すぎます」で締め出されます。ログには「Rate limiting could not determine a client IP and is falling back to a single shared per-path bucket」という警告が1回だけ出ます。ALBやnginxの後ろに置くなら、次のどちらかを設定します。
export const auth = betterAuth({
advanced: {
ipAddress: {
// プロキシ自身のアドレス。クライアントも含む広いプライベート帯は書かない
trustedProxies: ["10.0.0.7"],
// または、プロキシが単一値で入れるヘッダを読む
// ipAddressHeaders: ["x-real-ip"], // Cloudflareなら "cf-connecting-ip"
},
},
});
trustedProxies はヘッダの連鎖を右から読み、信頼したプロキシを飛ばして最初の信頼外アドレスを利用者のIPとします。送信元そのものは検証しないので、オリジンにはプロキシ経由でしか届かないようにしておきます。IPv6は既定で /64 単位で数えます。ログイン試行の総当たりとレート制限の設計全体はパスワードリスト攻撃の検知とレート制限の実装で扱っています。
Upstashやnode-redisで自前実装する場合
公式パッケージがioredis前提なので、HTTP経由のUpstash Redisや node-redis を使うときは SecondaryStorage インターフェースを自分で実装します。必要なメソッドは get・getAndDelete・set・delete・increment の5つです。increment はレート制限用で、INCR と EXPIRE ... NX をトランザクションでまとめる実装例が公式にあります。NX を付けないと、リクエストのたびにTTLが延びて制限窓が終わりません。Edge環境でUpstashを使う場合の料金と接続方法はUpstashの料金と無料枠にまとめています。
既定はJWTではなくサーバー側で照合するセッション
Better Authのログイン状態は better-auth.session_token というCookieで運ばれます。HTTPのlocalhostを使った1.7.12の実測では Max-Age=604800; HttpOnly; SameSite=Lax で発行されました。baseURL が https:// なら、Cookie名に __Secure- が付きSecure属性も設定されます(1.7.12の実装で確認)。中身はランダムなトークンで、サーバーはそれをDB(またはRedis)で引いてユーザーを特定します。updateAge(既定1日)を過ぎてから使われると、有効期限がその時点から7日後に更新されます。これがいわゆる「リフレッシュ」で、別途リフレッシュトークンを発行する仕組みではありません。
サーバー側で照合するので、ログアウトや失効は即座に効きます。
毎回DBを引くのを避けたいなら session.cookieCache を有効にします。セッションの写しを短命なCookie(session_data)に入れ、maxAge の間はDBを引きません。
| strategy | 形式 | 中身の可読性 | 向く場面 |
|---|---|---|---|
compact(既定) |
base64url+HMAC-SHA256 | 読める | Better Auth内だけで使う |
jwt |
HS256のJWT | 読める | 外部ツールで検証したい |
jwe |
A256CBC-HS512で暗号化 | 読めない | セッション内容を隠したい |
代償は失効の遅れです。別の端末でセッションを取り消しても、maxAge が切れるまでは有効に見えます。公式は即時失効が必要なら無効にするか maxAge を60秒程度に縮めるよう案内しており、権限の取り消しを即時反映させたい管理画面では使わないのが妥当です。
JWTプラグインとBearerプラグインの役割の違い
APIサーバーやマイクロサービスにJWTを渡したいときに使うのがJWTプラグインです。公式は「not meant as a replacement for the session」と明記しており、セッションはCookieのまま、外部サービス向けに別途JWTを発行します。
import { jwt, bearer } from "better-auth/plugins";
export const auth = betterAuth({
// ...
plugins: [jwt(), bearer()],
});
// 受け側(別サービス)での検証
import { jwtVerify, createRemoteJWKSet } from "jose";
const JWKS = createRemoteJWKSet(new URL("https://app.example.com/api/auth/jwks"));
const { payload } = await jwtVerify(token, JWKS, {
issuer: "https://app.example.com",
audience: "https://app.example.com",
});
JWTは GET /api/auth/token か、getSession のレスポンスヘッダ set-auth-jwt から取り出します。v1.7.12で発行されたトークンのヘッダをデコードすると署名アルゴリズムはEdDSA(kid 付き)、exp と iat の差は900秒(15分)、sub はユーザーID、iss と aud はどちらも baseURL でした。検証用の公開鍵は /api/auth/jwks で公開されます。1.7.12からは、JWTとcookieCacheのJWTを保存済みの鍵のアルゴリズムで検証するよう修正されています(v1.7.12のリリースノート)。
Bearerプラグインは、Cookieを送れないモバイルアプリやCLIのために、セッショントークン自体を Authorization: Bearer ヘッダで受け付けます。トークンはサインイン時の set-auth-token ヘッダで返ります。公式は「Use this cautiously」と警告しており、SPAがlocalStorageに保存するとXSSで盗まれる経路が増えます。ブラウザはCookie、ネイティブアプリはBearerと分けてください。ヘッダの仕様とJWTとの違いはBearer TokenとAuthorizationヘッダの書き方で解説しています。
TypeScript以外(Flutter・Go・PHP・Java)から使う構成
公式ドキュメントのクライアントは better-auth/react・vue・svelte・solid と、フレームワークに依存しない better-auth/client です。React Nativeは @better-auth/expo を使い、Cookieを expo-secure-store に保存します。サーバー本体もNode.js・Bun・Cloudflare Workersなどで動くTypeScriptなので、LaravelやSpringの中にBetter Authを組み込むことはできません。
それ以外の言語からは、認証サーバーをTypeScriptで1つ立て、HTTPで使います。役割で2通りに分かれます。
- ログインする側(Flutter・Swift・Kotlinのアプリ):
POST /api/auth/sign-in/emailにJSONを送り、返るset-auth-tokenを端末の安全な保存領域に置きます。以降はAuthorization: Bearerを付けて/api/auth/get-sessionなどを呼びます。1.7.12の実測では、BearerだけでCookie無しのget-sessionが200を返し、同時にset-auth-jwtも付きました。 - トークンを受け取る側(Go・PHP・JavaのAPI):JWTプラグインのJWTを受け、
/api/auth/jwksの公開鍵で検証します。EdDSA(Ed25519)に対応したJOSEライブラリを選ぶ点だけ注意します。毎回認証サーバーへ問い合わせたくない構成に向きます。
リフレッシュトークン:OAuthプロバイダのアクセストークン更新
Better AuthのリフレッシュトークンはGoogleなどOAuthプロバイダが発行するものを指します。受け取ったアクセストークンとリフレッシュトークンはaccountテーブルに保存され、getAccessToken を呼ぶと期限切れなら自動で更新してから返します。v1.7では対象アカウントを accountId(listAccounts で取得)か useAccountCookie: true で明示する必要があります。すぐに更新したいときは refreshToken を呼びます。
Googleには固有の癖があります。リフレッシュトークンを発行するのは初回の同意時だけで、2回目以降のログインではアクセストークンしか返りません。常に受け取りたいなら、Googleプロバイダに accessType: "offline" と prompt: "select_account consent" を指定します。OAuthのトークン種別と認可フローの基礎はOAuth 2.0の仕組みと認可フローを参照してください。
2要素認証とGoogleログインの実装
twoFactorプラグインでTOTPを有効化
2FAは twoFactor プラグインで追加します。サーバー設定の appName が認証アプリに表示される発行者名(issuer)になり、プラグイン追加後はテーブル追加のため npx auth migrate が必要です。
// lib/auth.ts(サーバー)
import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";
import { Pool } from "pg";
export const auth = betterAuth({
appName: "My App",
database: new Pool({ connectionString: process.env.DATABASE_URL }),
emailAndPassword: { enabled: true },
plugins: [twoFactor()],
});
// lib/auth-client.ts(クライアント)
import { createAuthClient } from "better-auth/client";
import { twoFactorClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({ plugins: [twoFactorClient()] });
// ログイン済みのユーザーが有効化(TOTPのURIとバックアップコードが返る)
const { data } = await authClient.twoFactor.enable({ password: "現在のパスワード" });
// data.totpURI をQRコードにして認証アプリで読み取らせる
// 初回設定:認証アプリに表示されたコードを検証してTOTPを有効にする
// 後続のサインインで twoFactorRedirect が true の場合も、同じメソッドで入力コードを検証する
await authClient.twoFactor.verifyTotp({ code: "123456", trustDevice: true });
TOTPは、最初のコードを検証するまで有効になりません。trustDevice: true を渡すと既定ではその端末を30日間信頼し、期間内のサインインでは2FAを省略します。信頼期間はサインインに成功するたびに更新されます。既定で2FAの確認対象になるのは /sign-in/email・/sign-in/username・/sign-in/phone-number の3つだけで(1.7.12のプラグイン実装で確認)、Googleログインやマジックリンク、パスキーでのサインインには2FAがかかりません。「2FAを入れたから全経路が守られる」と考えると穴が残ります。パスワードを持たないOAuthユーザーに2FAを設定させたい場合は allowPasswordless: true が必要です。
GoogleログインのリダイレクトURIとワンタップ設定
Googleログインは socialProviders.google に clientId と clientSecret を渡し、Google Cloud Consoleの承認済みリダイレクトURIに https://<ドメイン>/api/auth/callback/google を登録します。開発時は http://localhost:3000/api/auth/callback/google も足します。本番では BETTER_AUTH_URL(または baseURL)を必ず設定します。未設定だと受信リクエストからオリジンを推定するため、プロキシ配下ではGoogleに登録したリダイレクトURIと食い違ってコールバックが失敗することがあります。
画面遷移なしでログインさせたいなら、サーバーに oneTap()、クライアントに oneTapClient({ clientId: "..." }) を登録し、authClient.oneTap() を呼びます。ユーザーがGoogleのアカウント選択で1つ選ぶだけで、Better Authのセッションが作られます。サーバー側の oneTap() は clientId を省略すると socialProviders.google の値を使います。
セキュリティの既定値:パスワードハッシュ・CSRF・レート制限
- パスワードハッシュ:既定はscrypt(bcryptではありません)。1.7.12の保存値は「ソルト32桁:ハッシュ128桁」の16進でした。
password.hash/verifyを渡せばargon2idやbcryptに差し替えられます。 - CSRF:リクエストの
OriginをBETTER_AUTH_URLとtrustedOriginsに照合し、Cookieは既定でSameSite=Lax。Cookieを持たない初回ログインはSec-Fetch-SiteなどのFetch Metadataで判定します。信頼外のOriginからサインインを送ると403INVALID_ORIGINが返りました。 - レート制限:本番ではHTTPで届くリクエストに10秒100回。
/sign-in・/sign-up・/change-password・/change-emailで始まるパスは10秒3回、パスワードリセットと確認メールの送信系は60秒3回です。IPv4はアドレスごと、IPv6は/64単位で数えます。
設定で最も危ないのは disableOriginCheck: true です。名前はリダイレクト先の検証だけを外すように見えますが、後方互換のためCSRF保護もまとめて無効になります。フロントエンドを別ドメインに置いてOriginエラーが出たときは、この設定ではなく trustedOrigins にそのドメインを追加して解決します。CSRFの攻撃手順とXSSとの違いはXSSとCSRFの違いと対策を参照してください。
採用を見送る条件と、2026年の脆弱性への追従
GitHubのSecurity AdvisoriesをAPIで全件取得して数えると(累計37件)、2026年1月から10月23日までに公開されたものは27件(critical 3、high 17、medium 5、low 2)でした。9月28日時点の22件から1か月足らずで5件増え、プラグインだけでなく本体の組み合わせで起きる重大なものも含まれます。
| 公開日 | 深刻度 | 影響する構成 | 修正版 |
|---|---|---|---|
| 2026-09-30 | critical | Magic Link+ソーシャル/Generic OAuth | 1.7.7 |
| 2026-09-30 | high | OAuth Proxyプラグイン | 1.7.7 |
| 2026-09-29 | high | @better-auth/sso の個人SSO |
1.7.3 |
| 2026-09-28 | high | Device Authorization | 1.7.0 |
| 2026-05-31 | low | セッションをsecondaryStorageに保存 | 1.6.11 |
最上段の GHSA-965c-763c-88jm は、1.4.0-beta.18以上1.7.7未満でOAuthのstateをMagic Linkとして使われ、他人としてサインインされるものです。1.7.7のリリースノートは、検証用ストレージを共有するサーバーを同時に更新し、発行済みのMagic Linkと処理中のOAuthサインインをやり直させるよう求めています。最下段はRedis構成に直接関わり、削除したユーザーがログインしたまま残るものでした。マイナー版でも破壊的変更は入り、1.7(2026年8月17日発表)では旧 oidcProvider プラグインが削除され @better-auth/oauth-provider への移行が必須になりました。1.8は2026年10月22日時点でベータ(1.8.0-beta.2)です。
次の条件に当てはまるなら、Better Authは採用しない方が安全です。
- 依存パッケージの定期更新に加え、重大な脆弱性の公開時に影響を判定し、必要な修正版を緊急適用する運用が組めない。この場合はAuth0・Clerk・WorkOSなどのマネージド認証に任せる方が、認証基盤そのものの脆弱性対応を提供者に任せられます。SDKの更新とアプリ側の設定は引き続き自分の責任です。
- 既にSupabaseをDBに使っていて、RLSと認証を一体で扱いたい。Supabase Authの方が行レベルの認可まで設定が短く済みます(Supabase AuthのJWT鍵とRLS連携)。
- Auth.js v4でOAuthログインだけを使い、安定稼働している。移行ガイド自身が急ぐ必要はないと書いており、テーブル移行のリスクに見合いません。
逆に、自社DBにユーザーを置いたまま2FA・パスキー・組織管理まで一通り揃えたいTypeScriptのプロジェクトなら、それぞれの機能を設定とプラグインの追加で揃えられるBetter Authが第一候補です。その場合は1.7系の最新パッチを使い、Better Authのリリース通知を購読し、自社リポジトリではDependabot alertsを有効にして脆弱な依存関係の通知を受け取り、npx auth@latest upgrade で本体と @better-auth/* の版をそろえて更新します。
better-authに関するよくある質問
better-authは無料で使えますか?料金はかかりますか?
本体はMITライセンスのオープンソースで、ユーザー数に関係なく無料です。料金がかかるのは、ダッシュボード・監査ログ・不正検知・メール/SMS送信を提供する「Better Auth Infrastructure」を使う場合だけで、Starterは$0、Proは月$20からです。
better-authとAuth.js(NextAuth)はどちらを選べばよいですか?
新規プロジェクトならBetter Authです。2025年9月からAuth.jsの保守もBetter Authチームが担い、新規には公式がBetter Authを強く推奨しています。Auth.js v4で安定稼働している既存システムは、セキュリティパッチが続くため急いで移行する必要はありません。
better-authでJWT認証はできますか?
できますが、既定のセッションはJWTではなく、Cookieのセッショントークンをサーバー側で照合する方式です。外部のAPIサーバーにJWTを渡したいならJWTプラグインを足します。JWTプラグインの既定設定ではEdDSA署名・有効期限15分で発行され、受け側は /api/auth/jwks の公開鍵で検証します。
better-authでRedisは必須ですか?
必須ではありません。既定ではセッションはDB、レート制限はメモリで動きます。Redisを足すのは、DBへのセッション照会を減らしたいときと、複数台やサーバーレスでレート制限のカウンタを共有したいときです。secondaryStorage を渡すとセッションの保存先はDBからRedisに切り替わります。
better-authはFlutterやPHPのアプリでも使えますか?
Better Authのサーバー本体はTypeScriptなので、認証サーバーをNode.jsなどで別に立てれば使えます。Flutterなどのアプリは、認証サーバーでBearerプラグインを有効にしたうえで、サインインで受け取る set-auth-token を Authorization: Bearer で送り、PHPやGoのAPIはJWTプラグインのJWTを /api/auth/jwks の公開鍵で検証します。