3 つの Rork アプリを運営してきて、毎回いちばん時間を吸われたのは UI でも課金実装でもなく、認証まわりでした。Web 版を Clerk にしてモバイルを Supabase Auth、Apple Sign In だけ別実装、組織機能は自前で作って、と継ぎ接ぎしているうちに、ユーザー削除のときに 4 箇所更新しないといけないコードベースが完成してしまったのです。
その反省から、最近の新規プロジェクトでは Better Auth に一本化しています。Better Auth は 2024 年後半に登場した TypeScript ファーストの認証ライブラリで、フレームワーク非依存・プラグイン式・自前 DB という思想で作られています。Clerk のような SaaS と違って自分のサーバーで動かしますが、その代わり 1 つのバックエンドで Web ブラウザ・iOS・Android すべてを同じセッションモデルで扱えるのが最大の利点です。
ここで扱うのはRork で作ったモバイルアプリ(React Native + Expo)と Next.js の管理画面を、ひとつの Better Auth サーバーに統合する手順を、私が本番で踏んだ落とし穴と一緒にまとめます。コード例はすべて、執筆時点の Better Auth 1.2 系で動作確認済みです。
なぜいま Better Auth なのか — Clerk・Supabase Auth・Auth.js との違い
最初に「なぜ Clerk や Supabase Auth ではないのか」を整理しておきます。私自身どちらも本番で使ってきたので、向き不向きの判断軸を率直に書きます。
Clerk は UI コンポーネントが完成度高く、B2B 機能(組織・招待・SSO)が箱から出してすぐ使えます。一方で MAU 課金が積み上がりやすく、無料アプリで月 5 万 MAU を超えてくると課金体系が個人開発の収益と合わなくなります。Clerk はうまく行きすぎたときに困る選択肢でもあるのです。
Supabase Auth は Postgres と密結合で安いのですが、サーバーセッションが GoTrue (JWT) ベースで Cookie 同期がモバイルでやや扱いづらい印象です。Apple Sign In や Passkeys のような OS 固有機能は自分でラップする必要があります。
Auth.js (旧 NextAuth) は Web 中心で、React Native での公式サポートが弱いです。
そこに登場した Better Auth は、上記のいいとこ取りを TypeScript で実装したような設計です。具体的には、
セッションは DB に保存される Cookie + Token のハイブリッド 。Web は HttpOnly Cookie、モバイルは Bearer トークンとして同じ session レコードを参照する
認証メソッドは プラグイン 。メールパスワード・マジックリンク・ソーシャル・パスキー・OTP・2FA・組織・SSO・Stripe 連携などをコンポーザブルに足せる
DB アダプタは Drizzle・Prisma・Mongo・Kysely をサポート。Cloudflare D1 や Neon Postgres も Drizzle 経由で動く
フロントは React・Vue・Svelte・Solid・React Native に公式クライアントあり
つまり「Web でうまく動いた認証フローが、そのままモバイルでも同じ DB に書き込める」という体験を提供してくれます。私が継ぎ接ぎ運用に疲れた人向けには、これがいちばん効きます。
Better Auth が Rork アプリに合う 3 つの理由
Rork は React Native + Expo で生成されたアプリを npm publish のように Cloudflare に乗せて使う構成なので、認証バックエンドも Cloudflare 上で動かしたほうがレイテンシが揃います。Better Auth はこの構成と特に相性が良く、理由は 3 つあります。
第 1 に、Cloudflare Workers + Hono で Better Auth を素直に動かせる こと。betterAuth.handler は標準の Request を受け取って Response を返すだけなので、Hono ルートに app.all('/api/auth/*', c => auth.handler(c.req.raw)) と書くだけで動きます。
第 2 に、Drizzle ORM 経由で Cloudflare D1 や Neon Postgres を共有できる こと。Rork で作ったアプリのバックエンドにすでに Drizzle を使っているなら、認証用テーブルを既存スキーマに足すだけです。私の現プロジェクトでは Rork × Neon Postgres + Drizzle ORM 完全ガイド で組んだ DB をそのまま使い、auth-schema.ts だけ追加しました。
第 3 に、Expo の Deep Link と Cookie を両方サポートしている こと。これが地味に効きます。後述しますが、Apple Sign In のリダイレクト URL を myapp://auth/callback で受けつつ、Web 版では Cookie で同じセッションを引き継ぐ、という運用が可能です。
プロジェクトのセットアップ — モノレポ構成と Cloudflare Workers バックエンド
ここから実装に入ります。今回想定する構成はモノレポで、Web (Next.js)・モバイル (Rork で生成した Expo アプリ)・バックエンド (Cloudflare Workers + Hono) の 3 つを共通の auth/ パッケージで束ねます。
my-app/
├── apps/
│ ├── web/ # Next.js 15 (管理画面)
│ └── mobile/ # Rork で生成した Expo アプリ
├── packages/
│ ├── auth/ # Better Auth の設定(共通)
│ └── db/ # Drizzle スキーマ
└── workers/
└── api/ # Cloudflare Workers + Hono
packages/auth/server.ts で Better Auth サーバーを 1 つ作ります。下記コードはコピペで動かすために、必要な依存をすべて明示しています。
// packages/auth/server.ts
// 目的: Web/モバイル両方から呼ばれる Better Auth の本体。
// 動作環境: Cloudflare Workers + D1 or Neon Postgres
import { betterAuth } from "better-auth" ;
import { drizzleAdapter } from "better-auth/adapters/drizzle" ;
import { passkey } from "better-auth/plugins/passkey" ;
import { magicLink } from "better-auth/plugins/magic-link" ;
import { organization } from "better-auth/plugins/organization" ;
import { expo } from "@better-auth/expo" ;
import { db } from "@my-app/db" ;
export const auth = betterAuth ({
database: drizzleAdapter (db, { provider: "pg" }),
baseURL: process.env. BETTER_AUTH_URL ! , // 例: https://api.myapp.com
secret: process.env. BETTER_AUTH_SECRET ! , // 32 文字以上のランダム文字列
trustedOrigins: [
"https://myapp.com" ,
"https://admin.myapp.com" ,
// モバイルアプリのスキーム — Expo の app.json で設定したもの
"myapp://" ,
"exp://192.168.1.10:8081" , // 開発時の Expo Go
],
emailAndPassword: { enabled: true , requireEmailVerification: true },
socialProviders: {
apple: {
clientId: process.env. APPLE_CLIENT_ID ! ,
clientSecret: process.env. APPLE_CLIENT_SECRET ! ,
},
google: {
clientId: process.env. GOOGLE_CLIENT_ID ! ,
clientSecret: process.env. GOOGLE_CLIENT_SECRET ! ,
},
},
plugins: [
expo (), // ⚠️ React Native の Bearer トークン処理を有効にする
passkey ({ rpName: "MyApp" , rpID: "myapp.com" }),
magicLink ({
sendMagicLink : async ({ email , url }) => {
// Resend や AWS SES でメール送信。後述。
await sendEmail (email, "Sign in to MyApp" , url);
},
}),
organization ({ allowUserToCreateOrganization: true }),
],
});
async function sendEmail ( to : string , subject : string , url : string ) {
// 実装例は別記事 Rork × Resend メール送信統合ガイド 参照
throw new Error ( "Implement sendEmail with Resend or SES" );
}
ここで 絶対に忘れてはいけない のが expo() プラグインの追加です。これが無いと、モバイルからのリクエストで Set-Cookie を返そうとして失敗します(ブラウザではない環境では Cookie が保存できないので、Bearer トークンに切り替える必要があるのです)。私はこれに気づかず半日溶かしました。
次に Hono 側のルート定義です。
// workers/api/src/index.ts
import { Hono } from "hono" ;
import { cors } from "hono/cors" ;
import { auth } from "@my-app/auth/server" ;
const app = new Hono ();
app. use (
"/api/auth/*" ,
cors ({
origin : ( origin ) => origin, // trustedOrigins と一致するオリジンを返す
credentials: true ,
allowMethods: [ "GET" , "POST" , "OPTIONS" ],
})
);
app. all ( "/api/auth/*" , ( c ) => auth. handler (c.req.raw));
export default app;
credentials: true を忘れると Web 側で Cookie が往復しません。さらに origin: (origin) => origin のように動的に返さないと、ブラウザが Access-Control-Allow-Origin: * と credentials: true の組み合わせを拒否します。これも踏んだ落とし穴のひとつです。
メールリンク認証の実装(モバイル Deep Link 対応)
メールリンク(Magic Link)はパスワード不要で UX が良く、私が個人アプリで好んで使うフローです。しかしモバイルでは「メールアプリで開いたリンクが Safari で開いてしまい、自分のアプリには戻ってこない」という問題が起きます。これを Deep Link で解決します。
まず Rork で生成したアプリの app.json に Universal Link / App Link の設定を追加します。
{
"expo" : {
"scheme" : "myapp" ,
"ios" : {
"bundleIdentifier" : "com.example.myapp" ,
"associatedDomains" : [ "applinks:myapp.com" ]
},
"android" : {
"package" : "com.example.myapp" ,
"intentFilters" : [
{
"action" : "VIEW" ,
"data" : [{ "scheme" : "https" , "host" : "myapp.com" , "pathPrefix" : "/auth" }],
"category" : [ "BROWSABLE" , "DEFAULT" ],
"autoVerify" : true
}
]
}
}
}
そして https://myapp.com/.well-known/apple-app-site-association と https://myapp.com/.well-known/assetlinks.json を配信します。Cloudflare Workers から返すなら次のように書けます。
// workers/api/src/well-known.ts
app. get ( "/.well-known/apple-app-site-association" , ( c ) => {
return c. json ({
applinks: {
apps: [],
details: [
{
appID: "TEAMID.com.example.myapp" ,
paths: [ "/auth/*" ],
},
],
},
});
});
次にモバイル側でリンクを受け取る部分です。Expo Router を使っている前提で、/app/auth/callback.tsx を作ります。
// apps/mobile/app/auth/callback.tsx
// 目的: メールリンクや Apple Sign In のコールバックを受け取り、
// Better Auth クライアントでセッションを完成させる。
import { useEffect } from "react" ;
import { router, useLocalSearchParams } from "expo-router" ;
import { ActivityIndicator, View, Text } from "react-native" ;
import { authClient } from "@/lib/auth-client" ;
export default function AuthCallbackScreen () {
const params = useLocalSearchParams <{ token ?: string ; error ?: string }>();
useEffect (() => {
( async () => {
if (params.error) {
router. replace ({ pathname: "/sign-in" , params: { error: params.error } });
return ;
}
if ( ! params.token) {
router. replace ( "/sign-in" );
return ;
}
// Better Auth のマジックリンク確認エンドポイントを呼ぶ。
// expo() プラグインが Bearer トークンを SecureStore に保存してくれる。
const result = await authClient.magicLink. verify ({
query: { token: params.token },
});
if (result.error) {
router. replace ({ pathname: "/sign-in" , params: { error: result.error.message } });
return ;
}
router. replace ( "/(tabs)/home" );
})();
}, [params.token, params.error]);
return (
< View style = { { flex: 1 , alignItems: "center" , justifyContent: "center" } } >
< ActivityIndicator />
< Text style = { { marginTop: 12 } } >サインインを完了しています…</ Text >
</ View >
);
}
Better Auth のクライアント側はこう設定します。
// apps/mobile/lib/auth-client.ts
import { createAuthClient } from "better-auth/react" ;
import { expoClient } from "@better-auth/expo/client" ;
import * as SecureStore from "expo-secure-store" ;
export const authClient = createAuthClient ({
baseURL: process.env. EXPO_PUBLIC_BETTER_AUTH_URL ! , // https://api.myapp.com
plugins: [
expoClient ({
scheme: "myapp" ,
storagePrefix: "myapp" ,
storage: SecureStore, // ⚠️ AsyncStorage ではなく SecureStore を使う
}),
],
});
Bearer トークンは Keychain (iOS) / Keystore (Android) に保存される SecureStore に置きます。AsyncStorage に保存している実装を見ますが、ジェイルブレイク端末や物理アクセスのある攻撃に対して弱いので避けるべきです。
期待される動作: ユーザーがメールに届いたリンクをタップすると、対応 OS なら直接アプリが開き、/auth/callback?token=xxx で magicLink.verify() が呼ばれ、session レコードが DB に書かれて Bearer トークンが SecureStore に保存されます。Web ブラウザでタップした場合は Web 版にフォールバックし、Cookie でログイン状態になります。
ソーシャルログイン(Apple / Google)— モバイル特有の落とし穴
Apple Sign In は App Store 審査ガイドライン 4.8 で「他のソーシャルログインを提供するなら Apple Sign In も必須」と決まっているため、iOS リリース予定なら避けて通れません。Better Auth は OAuth フロー自体は隠蔽してくれますが、モバイル側では Expo の AuthSession か expo-apple-authentication を組み合わせる必要があります。
// apps/mobile/components/AppleSignInButton.tsx
import * as AppleAuthentication from "expo-apple-authentication" ;
import { authClient } from "@/lib/auth-client" ;
import { router } from "expo-router" ;
export function AppleSignInButton () {
const handlePress = async () => {
try {
const credential = await AppleAuthentication. signInAsync ({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope. FULL_NAME ,
AppleAuthentication.AppleAuthenticationScope. EMAIL ,
],
});
// identityToken を Better Auth サーバーに送って検証してもらう
const result = await authClient.signIn. social ({
provider: "apple" ,
idToken: { token: credential.identityToken ! , nonce: credential.nonce },
});
if (result.error) throw new Error (result.error.message);
router. replace ( "/(tabs)/home" );
} catch ( e : any ) {
if (e.code === "ERR_REQUEST_CANCELED" ) return ; // ユーザーキャンセル
console. error (e);
}
};
return (
< AppleAuthentication.AppleAuthenticationButton
buttonType = { AppleAuthentication.AppleAuthenticationButtonType. SIGN_IN }
buttonStyle = { AppleAuthentication.AppleAuthenticationButtonStyle. BLACK }
cornerRadius = { 12 }
style = { { width: "100%" , height: 48 } }
onPress = { handlePress }
/>
);
}
注意点 3 つ。第 1 に、Apple は メールアドレスを最初の 1 回しか返さない ので、サーバー側で初回ログイン時に必ず保存しないと、再ログインで匿名アカウントが量産されます。Better Auth はデフォルトで保存しますが、自前の onCreateUser フックがある場合は競合に注意してください。
第 2 に、Apple の identityToken は audience が App ID になっているので、Better Auth サーバーが期待する Bundle ID と完全一致する必要があります。com.example.myapp と com.example.MyApp は別物として扱われます(Apple のドキュメントには明示されていませんが、私はこれで 2 時間溶かしました)。
第 3 に、シミュレータでは Apple Sign In のテストがしづらいです。実機テストを前提に組み、シミュレータでは Google Sign In でフォールバックする UX を入れておくのが現実的です。
パスキー(Passkeys)対応 — iOS 17+ / Android 14+ で押さえる API
パスキーはユーザーの体験を一段引き上げます。Face ID / Touch ID / 指紋でログインできるので、ユーザーは何も覚える必要がありません。Better Auth の passkey プラグインを使うと、WebAuthn の煩雑な部分はほぼ隠蔽されます。
// apps/mobile/components/PasskeyEnrollButton.tsx
import { Pressable, Text, Alert } from "react-native" ;
import { authClient } from "@/lib/auth-client" ;
export function PasskeyEnrollButton () {
const handlePress = async () => {
try {
const { data , error } = await authClient.passkey. addPasskey ();
if (error) throw new Error (error.message);
Alert. alert ( "成功" , "パスキーを登録しました。次回からは Face ID でログインできます。" );
} catch ( e : any ) {
Alert. alert ( "エラー" , e.message);
}
};
return (
< Pressable onPress = { handlePress } style = { { padding: 16 , backgroundColor: "#000" , borderRadius: 12 } } >
< Text style = { { color: "#fff" , textAlign: "center" } } >このデバイスでパスキーを設定</ Text >
</ Pressable >
);
}
実装上の注意は、rpID が Universal Link のドメインと一致している必要がある 点です。今回の例では rpID: "myapp.com" なので、/.well-known/apple-app-site-association の webcredentials セクションに myapp.com を追加する必要があります。これを忘れるとパスキー登録時に「対応していません」と無言で失敗します。
{
"webcredentials" : { "apps" : [ "TEAMID.com.example.myapp" ] },
"applinks" : { "details" : [ /* ... */ ] }
}
期待される動作: ボタンを押すと OS のパスキー登録 UI(Face ID 認証 → iCloud キーチェーンに保存)が立ち上がり、登録後は次回サインイン時に authClient.signIn.passkey() で Face ID だけでログインできます。
組織(Organizations)プラグインで B2B SaaS を構築する
個人開発者でも B2B SaaS を作るタイミングは意外と来ます。私も最近、写真管理アプリを「家族プラン」として複数アカウント共有できるようにしました。Better Auth の organization プラグインを使うと、組織・メンバー・招待・権限の 4 つが箱から出た状態で揃います。
// apps/mobile/screens/CreateOrganization.tsx で呼び出す
const result = await authClient.organization. create ({
name: "Hirokawa Family" ,
slug: "hirokawa-family" ,
});
// result.data.organization が作られた組織
// メンバー招待
await authClient.organization. inviteMember ({
organizationId: result.data.organization.id,
email: "wife@example.com" ,
role: "admin" ,
});
// アクティブ組織の切り替え(モバイルでもアプリ起動時に呼ぶ)
await authClient.organization. setActive ({ organizationId: orgId });
ここで効くのは、セッションに activeOrganizationId が乗る ことです。バックエンドの API ハンドラで auth.api.getSession() を呼ぶと session.activeOrganizationId が返るので、Hono のミドルウェアで自動的に「ユーザーがアクティブにしている組織のデータだけ返す」ようなテナント分離ができます。
app. use ( "/api/photos/*" , async ( c , next ) => {
const session = await auth.api. getSession ({ headers: c.req.raw.headers });
if ( ! session?.session.activeOrganizationId) {
return c. json ({ error: "No active organization" }, 403 );
}
c. set ( "orgId" , session.session.activeOrganizationId);
await next ();
});
app. get ( "/api/photos" , async ( c ) => {
const orgId = c. get ( "orgId" );
const photos = await db. select (). from (photosTable). where ( eq (photosTable.orgId, orgId));
return c. json (photos);
});
Rork × B2B SaaS マルチテナント RBAC 完全ガイド で別途 RBAC を組んでいる方は、組織プラグインのロール定義をそちらに統合できます。
セッション同期 — Web ブラウザとモバイルアプリで同一ユーザーを扱う
ここがいちばん「Better Auth に切り替えてよかった」と感じる部分です。Web で useSession()、モバイルで useSession()、どちらも Better Auth の session テーブルを参照しているので、サーバー側のロジックがひとつで済みます。
// apps/mobile/app/(tabs)/profile.tsx
import { authClient } from "@/lib/auth-client" ;
export default function ProfileScreen () {
const { data : session , isPending } = authClient. useSession ();
if (isPending) return < ActivityIndicator />;
if ( ! session) return < Redirect href = "/sign-in" />;
return (
< View >
< Text > { session.user.name } </ Text >
< Text >Active Org: { session.session.activeOrganizationId ?? "なし" } </ Text >
</ View >
);
}
// apps/web/app/profile/page.tsx (Next.js Server Component)
import { auth } from "@my-app/auth/server" ;
import { headers } from "next/headers" ;
export default async function ProfilePage () {
const session = await auth.api. getSession ({ headers: await headers () });
if ( ! session) redirect ( "/sign-in" );
return < main > { session.user.name } </ main >;
}
Web は Cookie、モバイルは Bearer、でも session.user.id も session.session.activeOrganizationId も同じ値が返ります。サーバーの API はこの ID を信頼して動かすだけです。
よくあるエラーと対処法
ここからは、私が実際にぶつかったエラーと対処を、再現しやすい順にまとめます。
エラー 1: モバイルで Cookie has been blocked と出る
Bearer トークン認証になっていない兆候です。サーバー側で expo() プラグインが入っているか、クライアント側で expoClient() プラグインが入っているか確認してください。両方無いと Web の Cookie 仕様で動こうとして失敗します。
エラー 2: Apple Sign In で invalid_client が返る
Apple Developer Console で作った Service ID(例 com.example.myapp.signin)と、APPLE_CLIENT_ID 環境変数が一致していますか? Bundle ID ではなく Service ID が必要です。これは Apple のドキュメントが分かりにくく、私を含めて多くの人が踏みます。
エラー 3: マジックリンクをタップしても Safari で開く
Universal Link が機能していません。/.well-known/apple-app-site-association を Content-Type: application/json で配信していますか? text/html だと Apple が認識しません。Cloudflare Workers の場合は c.json() を使えば自動的に正しい Content-Type が付きます。
エラー 4: Cloudflare Workers で crypto.subtle is undefined
compatibility_flags = ["nodejs_compat"] を wrangler.toml に追加して、compatibility_date を 2024-09-23 以降にしてください。古い設定では Better Auth が依存する Web Crypto が動きません。
エラー 5: パスキー登録時に NotAllowedError
rpID がドメインと完全一致していないか、webcredentials の .well-known 設定が不足しています。デバッグは Safari の Web Inspector → コンソールで WebAuthn 関連の詳細エラーを見るのが速いです。
エラー 6: trustedOrigins に書き忘れる
モバイル側で myapp:// を trustedOrigins に追加していないと、CSRF 検証で弾かれます。本番ドメインだけ追加してテストしているとここでハマります。
本番運用チェックリスト — レート制限・CSRF・モニタリング
最後に、私が本番デプロイ前に必ず確認している項目です。
レート制限 : Better Auth はデフォルトでメモリベースのレート制限を持ちますが、Cloudflare Workers のような分散環境では効きません。KV か Durable Objects をストレージとして指定してください。
betterAuth ({
rateLimit: {
enabled: true ,
storage: "secondary-storage" ,
customStorage: {
get : async ( key ) => env. AUTH_KV . get (key),
set : async ( key , value , ttl ) => env. AUTH_KV . put (key, value, { expirationTtl: ttl }),
delete : async ( key ) => env. AUTH_KV . delete (key),
},
},
});
CSRF 対策 : Better Auth は Origin チェックで CSRF を防ぎますが、trustedOrigins を * にしていると無効化されます。本番では明示的にホワイトリストに書いてください。
監査ログ : ログイン成功/失敗、組織作成、メンバー追加などのイベントは別テーブルに保存しておくと、後で「なぜこのアカウントは権限が変わったのか」を追えます。Better Auth は events フックでこれらをすべて取れます。
betterAuth ({
// ...
hooks: {
after: [
{
matcher : ( ctx ) => ctx.path. startsWith ( "/sign-in" ),
handler : async ( ctx ) => {
await db. insert (auditLog). values ({
userId: ctx.context.session?.userId,
action: "sign-in" ,
ip: ctx.request.headers. get ( "cf-connecting-ip" ) ?? "" ,
createdAt: new Date (),
});
},
},
],
},
});
Sentry 連携 : 認証エラーは Sentry で個別タグを付けてアラートを上げています。詳細は Rork × Sentry クラッシュ監視セットアップガイド で実装手順を書いています。
全体を振り返って — 次の一歩
ここまで読んでくださってありがとうございます。Web とモバイルで認証基盤が分かれている状態は、コードの重複だけでなく「セキュリティアップデートを 2 か所に当てる」というメンテナンスコストを生み続けます。Better Auth に統合してしまえば、その負債を一気に返済できます。
明日からの一歩としておすすめなのは、新規プロジェクトをひとつだけ Better Auth で組んでみる ことです。既存の Clerk プロジェクトを移行するのは骨が折れますが、新しく作るアプリなら学習コストが移行コストに置き換わるだけで、しかも結果として 2 つ目以降のアプリの認証が一瞬で立ち上がるようになります。私も 2 個目から作業時間が半分以下になりました。
実装で詰まった箇所があれば、Better Auth の Discord に行くと作者の Bereket さんが直接回答してくれることが多いです。OSS 認証ライブラリで作者と直接会話できるのは贅沢なことなので、ぜひ活用してみてください。
技術書籍で TypeScript ファーストの認証設計をさらに