Better-Auth
2026年8月12日https://www.better-auth.com/docs/introduction
クライアント側(
createAuthClient)とサーバー側(auth.api)で同名の同じ機能のメソッドが用意されているのでimport元に注意以下はNextjsでの使い方をベースに書いているが、フレームワーク間で使い方に差は無い。明確に異なるのはAPIルートの設定
docker-compose.yml:
services:
db:
image: postgres:16
container_name: better-auth-form
ports:
- "5432:5432"
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: better-auth-form
volumes:
- better-auth-form:/var/lib/postgresql/data
volumes:
better-auth-form:Email / Password
const res = await signIn.email(
{
email: value.email,
password: value.password,
},
{
onError: (ctx) => {
console.log(ctx);
if (ctx.error.status === 403) {
alert("Please verify your email address");
}
},
},
);Email/Passを持ったUserが既に存在するか確認:
signUp時:登録済みの場合は
res.error.messageからその事をクライアントで表示できる。signIn時:登録済みの場合に「Passが間違っている」という情報は得られない。Emailが既に存在するという情報自体セキュリティ的に良くないためBetter-authがそういう仕様にしている。
Email認証
// auth.ts
import prisma from "@/prisma/prisma";
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "postgresql",
}),
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
},
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, url }) => {
console.log(`${user.email}: ${url}`);
// await sendResend(user.email, url)
},
},
});Email / PassでSignupするとOAuthでSignup/inする場合と違いEmail認証がされていない。
sendOnSignUpをtrueにするとSignup時にsendVerificationEmail()が呼び出される。sendVerificationEmail():userがformで入力したEmailに対し、アクティベーション用URL(http://localhost:3000/api/auth/verify-email?token=)を送信してクリックさせることでEmail認証がtrueになる。
requireEmailVerificationをtrueにしてもsignUp時にDBにUserが作成されるので登録行為自体を弾くことはできない。弾きたいならhooks側で設定する。
再送:
user.emailVerifiedかsignIn.email時のresがres.error.code === "EMAIL_NOT_VERIFIEDにより未認証であることがわかるので、この時に再送メールを出すボタンを表示する。
const handleResend = async () => {
await sendVerificationEmail({
email: user!.email,
callbackURL: "/",
});
};auth.tsの
sendVerificationEmail()が実行される。
Passwordの変更・リセット
変更
import { changePassword } from "@/lib/auth-client";
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const currentPassword = formData.get("current-password") as string;
const newPassword = formData.get("new-password") as string;
const res = await changePassword({
newPassword: newPassword,
currentPassword: currentPassword,
revokeOtherSessions: true,
});
router.push("/dashboard");
}changePassword()パスワード変更はログインしている状態から行う + currentPasswordも要求するのでEmail送信は無くてもいい。
リセット
https://better-auth.com/docs/authentication/email-password#request-password-reset
// auth.ts
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
sendResetPassword: async ({ user, url, token }) => {
console.log(`【開発用リセットURL】 for ${user.email}: ${url}, ${token}`, user);
// await sendEmail({ email: user.email, name: user.name, url });
},
},リセット = 「パスワードを忘れた」機能は、ログインしていない状態から行うのでセキュリティの観点からEmail送信などが必須
ログインしていないのにuserを引数として受け取れるのは、クライアント側で
requestPasswordReset()に渡したEmailを持つuserをDBから探索し取得しているため。
// ページA
const handleForgotPassword = async () => {
await createAuthClient().requestPasswordReset({
email: email,
redirectTo: "/reset-password",
});
toast.success(`${email}にEmailを送信しました。`);
};requestPasswordReset({ email, redirectTo })を実行し、UserのEmailにアクティベーションURL(ページB)を渡す。
// ページB
const searchParams = useSearchParams();
const token = searchParams.get("token");
const handleResetPassword = async (e: React.FormEvent) => {
e.preventDefault();
setIsPending(true);
const { error } = await createAuthClient().resetPassword({
newPassword: newPassword,
token: token,
});
console.log(error);
if (error) {
toast.error(`エラーが発生しました: ${error.message}`);
} else {
toast.success("パスワードが正常に更新されました!");
router.push("/");
}
};searchParamsからtokenを抽出し新しいパスワードと共に
resetPassword()へ渡す。
Prisma統合
Schema:https://www.prisma.io/docs/guides/betterauth-nextjs
新規アプリであれば
npx auth generateでbetter-auth関連のモデルを生成、既存のアプリに追加する場合はDocsからコピーしてペースト
auth.ts:
export const auth = betterAuth({
plugins: [nextCookies()],
socialProviders: {
google: {
clientId: process.env.AUTH_GOOGLE_ID as string,
clientSecret: process.env.AUTH_GOOGLE_SECRET as string,
},
},
database: prismaAdapter(prisma, {
provider: "postgresql",
}),
trustedOrigins: [env.BASE_URL as string],
});Google OAuthを使用する最小構成
セッションクッキー
export const AUTH_SESSION_COOKIE = {
prod: "__Secure-better-auth.session_token",
dev: "better-auth.session_token",
};上記はデフォルトのセッションcookie名
productionだと
__Secureがcookie名の接頭辞につくので、開発時と本番ではcookie名が異なるので三項演算などが必要になる。これはcookie名を変えても同様。__Secureはhttpsじゃないとcookieを保存しないというブラウザ側の仕組み
// auth.ts
advanced: {
cookies: {
session_token: {
name: "my_app_session_token",
attributes: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
},
},
},
},名前を変える場合
SignIn / Out / Session
サーバー
サーバー側の場合 haeders()を使うので "use server"を書いたファイルから関数をexportする。
SignIn:
const Page = () => {
return (
<form action={signInServer}>
<button type="submit">Sign in with Google</button>
</form>
);
};"use server";
import { redirect } from "next/navigation";
import { auth } from "../lib/auth";
export async function signInServer() {
const res = await auth.api.signInSocial({
body: {
provider: "google",
},
});
if (res.url) {
redirect(res.url);
}
}auth.tsにplugins: [nextCookies()]を追加しないとGoogleのURLに遷移せずにエラーになる。
SignOut:
"use server";
import { headers } from "next/headers";
import { auth } from "../auth/auth";
export async function signOutServer() {
await auth.api.signOut({
headers: await headers(),
});
}Session:
const data = await auth.api.getSession({
headers: await headers(),
});
---
export const getServerSession = async () => {
return await auth.api.getSession({
headers: await headers(),
});
};クライアント
import { createAuthClient } from "better-auth/react";
export const { signIn, signUp, signOut, useSession } = createAuthClient();SignIn / Out:
const GoogleSignIn = () => {
const pathname = usePathname();
return (
<button
type="submit"
className="cursor-pointer"
onClick={() =>
signIn.social({
provider: "google",
callbackURL: pathname,
})
}
>
<FcGoogle size={20} />
</button>
);
};
export default GoogleSignIn;
export const GoogleSignOut = () => {
return (
<Button
onClick={() => {
signOut();
router.replace("/test");
}}
type="submit"
variant={"link"}
className="cursor-pointer bg-red-500 text-white"
>
Sign out
</Button>
);
};signInはcallbackURLが無いと"/"にリダイレクトされるので、ログインしたルートにredirectさせるにはusePathname等で現在のパスを取得する。
Session:
const { data, error } = useSession();ユーザー
独自ユーザーの作成
基本的にはPrismaAdapterで作成されたUserを拡充するだけで充分だが、独自のUserテーブルを作成したい場合
databaseHooks:
// auth.ts
export const auth = betterAuth({
...
databaseHooks: {
user: {
create: {
after: async (user) => {
if (!user?.email) return;
const existingUser = await prisma.app_user.findUnique({
where: { email: user.email },
});
if (existingUser) return;
await prisma.app_user.create({
data: {
email: user.email,
name: user.name,
image: user.image,
},
});
},
},
},
},
});アカウントリンク
//auth.ts
account: {
accountLinking: {
enabled: true,
trustedProviders: ["google", "github", "email-password"],
},
},OAuth同士のみの場合
OAuth同士のリンク、つまりアプリのログイン機能がGoogle+Githubのようなケースでは明示的に
accountLinkingを書く必要はない。必要なのはEmail/Passも絡む時。最初にOAuthでSignInして出来るuserテーブルのデータが本体で、accountテーブルにはemailが同じ別のOAuthプロバイダーでログインする毎に同じuserを指すレコードが作成される。
OAuth登録のあとにEmail/Passを追加
<form action={setPasswordAction}>
<input
type="password"
name="password"
/>
<button type="submit">パスワードを設定</button>
</form>"use server";
import { headers } from "next/headers";
import { auth } from "./auth";
export async function setPasswordAction(formData: FormData) {
const password = formData.get("password") as string;
await auth.api.setPassword({
body: {
newPassword: password,
},
headers: await headers(),
});
}setPassword()を使用*setPasswordはcreateAuthClient()には存在しない。
accoutテーブルにSignInしているOAuthと同じuserを指すcredential ( = email/passを使うログイン手段 )アカウントが追加される。
Email / Passwordで登録している場合
Email/Passwordで登録した後に同じEmailのGoogle等でSignInした場合、requireEmailVerificationを明示的にfalseにしている場合はOAuthアカウントが追加される。
Email/Password登録時にfalseだったemailVerifiedがtrueへ変わる。
emailAndPassword: {
enabled: true,
requireEmailVerification: featureFlags.NEED_EMAIL_VERIFICATION,
},hooks
以下はhooksでSignup/inを制限する例:
*基本はauth.ts側でSocialProvidersやemailAndPasswordのdisableSignUpをtrueにするだけで良い。デメリットはBetter Auth標準のエラーUIに流される点。hooksではより柔軟な制御ができる。
// auth.ts
import { APIError, createAuthMiddleware } from "better-auth/api";
hooks: {
before: createAuthMiddleware(async (ctx) => {
// Email/Passによる新規登録を完全にブロック
if (ctx.path === "/sign-up/email") {
throw new APIError("FORBIDDEN", {
message: "Registration is closed.",
});
}
// ログイン(Sign-In)時に role をチェック
if (ctx.path === "/sign-in/email") {
const email = ctx.body?.email;
if (!email) {
throw new APIError("BAD_REQUEST", {
message: "Email is required.",
});
}
const appUser = await prisma.db.findUnique({
where: { email: email },
});
// 存在しない、または admin じゃない場合は即座に 403 (FORBIDDEN) を投げる
if (!appUser || appUser.role !== "admin") {
throw new APIError("FORBIDDEN", {
message: "Access denied. Authorized users only.",
});
}
}
}),
},*
ctx.pathはクライアントのpage.tsxを置いているパスではなく、キャッチオールしているAPIルート(/api/auth/[...all])のパス。Email/Passのsign-upなら/api/auth/sign-up/email、OAuthなら/api/auth/callback/googleのように最初から固定されている。
const res = await signUp.email({
email: value.email,
password: value.password,
name: value.name,
});
if (res?.error) {
toast.error(res.error.message);クライアントでは
APIErrorのメッセージを受け取れる。