最初の管理者の作成
APP_SECRET と ADMIN_EMAILS で最初の管理者を作る手順と、セットアップを閉じるしくみを説明します。
デプロイした直後のサイトには、管理者がいません。このページでは、最初の管理者を作る手順と、知らない人が先に管理者になれないしくみを説明します。
セットアップ画面
管理者がいないサイトで /admin/ を開くと、/admin/setup のセットアップ画面が出ます。


手順は 3 つです。
- セットアップトークンの確認。
APP_SECRETの値、ADMIN_EMAILSに入っているメールアドレス、任意で名前を入力し、次へ を選びます。確認が通ると、ログインした状態で手順 2 に進みます。トークンの間違い、許可されていないアドレス、完了済みのセットアップは、それぞれ別のメッセージで知らせます。 - Passkey の登録。 Passkeyを登録 を選びます。ブラウザ、スマートフォン、またはパスワード管理アプリが Passkey を作り、指紋・顔・PIN で本人確認をします。画面には、Passkey が結びつくドメインを表示します。
- 完了。 セットアップが完了し、ログインした状態になります。お問い合わせ一覧を開く で管理画面に進みます。次回からは
/admin/loginで Passkey を使ってログインします。
完了したあとも、シークレットの APP_SECRET は残してください。管理画面のログインセッションに署名する鍵と、IP アドレスをハッシュにする鍵は、この値から作ります。削除するとログインとお問い合わせフォームが止まり、変えるとすべての管理者がログアウトされ、IP アドレスのハッシュも変わります。
別に SETUP_TOKEN を設定した場合(鍵を個別に設定する)は、セットアップではその値だけを受け付け、画面にもその名前が出ます。完了したら SETUP_TOKEN は削除してください。もう使いません。
手順 1 のあとで画面を離れても、/admin/setup をもう一度開けば手順 2 から続けられます。Passkey を登録する前に離れても、締め出されることはありません。
受け付ける条件
最初の管理者は、セットアップ用のエンドポイント /api/setup が作ります。受け付けるのは、次のすべてを満たすリクエストだけです。
- サイト自身から送られている(
Originヘッダーを確認します)。 https://で送られている(平文の HTTP はlocalhostでだけ使えます)。- 正しいセットアップトークンが含まれている。
SETUP_TOKENを設定していればその値、なければAPP_SECRETです(応答までの時間からトークンを推測されないよう、比較にかかる時間が一定になる方法で比べます)。 - メールアドレスが
ADMIN_EMAILSに入っている(大文字と小文字は区別しません)。
ほかにも、次の場合はセットアップを使えません。
- 回数制限をかけられないとき。 セットアップの試行回数には制限があります。Rate Limiting binding か、IP アドレスのハッシュに使う鍵がなければ、セットアップは止まります。
- 設定がないか、シークレットが短い、またはランダムでないとき。
APP_SECRETは 32 文字以上で、明らかにランダムな値(同じ文字の繰り返しなどでない)が必要です。SETUP_TOKEN、BETTER_AUTH_SECRET、IP_HASH_SECRETを設定する場合も同じです。BETTER_AUTH_TRUSTED_ORIGINSやBETTER_AUTH_SECRETSが設定されていても、セットアップは止まります。必要な設定がないか使えない値があると、セットアップ画面にその設定の名前を表示します。
セットアップの完了
最初の Passkey が登録されると、セットアップは完了します。それ以降、セットアップのエンドポイントは使えません。後で Passkey をすべて削除しても、完了したままです。意図して開き直す手順はPasskey が使えないとき、失くしたときにあります。
settings.setup_closed_at があるか、Passkey が 1 つでもあれば、完了しているとみなします。setup_closed_at は、最初の Passkey を登録する処理が書き込みます(セッションを参照)。
さらに、passkey テーブルに行が追加されるたびに、データベースのトリガーも setup_closed_at を書き込みます。そのため、Passkey をすべて削除しても setup_closed_at は残ります。
CREATE TRIGGER `passkey_closes_setup` AFTER INSERT ON `passkey`
BEGIN
INSERT OR IGNORE INTO `settings` (`key`, `value`)
VALUES ('setup_closed_at', CAST(strftime('%s', 'now') AS INTEGER) * 1000);
END;管理 API が受け付けるのは、ログインしていて、まだ ADMIN_EMAILS に入っていて、Passkey を持っているユーザーだけです。セットアップで得たセッションでできるのは、最初の Passkey の登録だけです。
セッション
以前にトークンを知っていた別の人が、同じアドレスでセットアップを実行し、そのセッションを持ち続けているかもしれません。次の決まりで、そうしたセッションが管理者のセッションにならないようにします。
- やり直すと古いセッションを消します。 すでにユーザーがいるアドレスでセットアップをやり直すと、そのユーザーの以前のセッションを削除します。
- 最初の Passkey は 1 つだけ通します。 Passkey を保存する前に、まずセットアップの完了を記録します。記録は、
setup_closed_atがまだないときだけ書き込む 1 つの SQL 文です。最初の Passkey の登録が同時に 2 つあっても、記録できるのは 1 つだけで、もう一方は 403 になります。 - 記録したらセッションをすべて消します。 この時点で存在するセッションは、セットアップで作ったものだけです。セットアップ画面は
createSessionを付けて Passkey を登録するので、Passkey より後に作られた新しいセッションが渡され、ログインしたままになります。 - 保存に失敗したら記録を戻します。 記録したあとで Passkey を保存できなかったときは、記録を取り消すので、セットアップをやり直せます。取り消すのは、
setup_closed_atがその登録の書き込んだ値のままで、Passkey が 1 つもないときだけです。 - セットアップは最後にもう一度確かめます。 セットアップはセッションを作ったあと、まだ完了していないかを確認します。その間に Passkey の登録が完了を記録していたら、作ったセッションを削除して 403 を返します。
- 完了後は Passkey を登録できません。 セットアップが完了したあとは、Passkey を持たないユーザーは Passkey を登録できません。
- 古いセッションは使えません。 最初の Passkey より前に作られたセッションは、管理 API が拒否します(401)。すでに Passkey を持つユーザーが Passkey の API で追加の登録をするときも、最初の Passkey 以降のセッションが必要です。そのため、古いセットアップのセッションでは Passkey を追加できません。管理画面には、Passkey を追加する画面はまだありません。
最初の Passkey を保存する直前に、Passkey 登録のフックが次の処理をします。完了済みなら止め、完了を記録し、セッションをすべて削除します。
if (await isSetupClosed(db)) {
throw new APIError('FORBIDDEN', { message: 'setup closed' });
}
const claim = await claimSetup(db);
if (!claim) throw new APIError('FORBIDDEN', { message: 'setup closed' });
claims.set(request, claim);
await getDatabase(db).delete(sessionTable);完了の記録は INSERT ... ON CONFLICT DO NOTHING の 1 文です。行を書き込めた登録だけが先に進めます。
export const claimSetup = async (db: D1Database): Promise<string | null> => {
const value = String(Date.now());
const rows = await getDatabase(db)
.insert(settings)
.values({ key: SETUP_CLOSED_AT, value })
.onConflictDoNothing()
.returning({ value: settings.value });
return rows.length === 1 ? value : null;
};管理 API は、セッションの作成時刻を、そのユーザーの最初の Passkey の作成時刻と比べます。
const first = await getDatabase(db)
.select({ createdAt: passkey.createdAt })
.from(passkey)
.where(and(eq(passkey.userId, userId), isNotNull(passkey.createdAt)))
.orderBy(asc(passkey.createdAt))
.get();
if (!first?.createdAt) return 'no_passkey';
return sessionCreatedAt.getTime() < first.createdAt.getTime()
? 'too_old'
: 'ok';なぜこう作るか
デプロイしたばかりのサイトは、持ち主がログインする前からインターネットに公開されています。セットアップ画面に最初に来た人が管理者になれるなら、先にサイトを見つけた知らない人に乗っ取られるおそれがあります。
そこで、持ち主だけが知っているシークレットと、許可したメールアドレスの両方を求めます。作成後はセットアップを使えなくするので、セットアップトークンが漏れても、後から管理者を増やされることはありません。
APP_SECRET をセットアップトークンに使えるのは、これがもともとセッションの鍵の元になっているからです。この値を知っている人はセッションの Cookie に署名できるので、セットアップよりも強い権限をすでに持っています。APP_SECRET をブラウザに入力したくない場合は、SETUP_TOKEN も設定してください。
Cloudflare Access で /admin、/admin/*、/api/admin/*、/api/setup、/api/auth/* を守れば、Access を通った人しかセットアップにたどり着けません。ACCESS_AUD と ACCESS_TEAM_DOMAIN を設定すれば、Worker も /api/setup で Access のトークンを確認します。セキュリティモデルを参照してください。