Passkey でのログイン
管理者が Better Auth の Passkey でログインするしくみと、パスワードを使わない理由を説明します。
管理者は、/admin/login で Passkey を使ってログインします。認証には Better Auth というライブラリを使います。メールとパスワードでのログインはありません。このページでは、そのしくみと理由を説明します。


使う前に知っておくこと
- Passkey は 1 つのドメインに結びつきます。 Passkey は独自ドメインを追加してから登録してください。
workers.devのアドレスで登録した Passkey は、独自ドメインでは使えません。 - 同期される Passkey を使ってください。 管理画面には、2 つ目の Passkey を追加する画面がまだありません。セットアップでブラウザが保存先を聞いてきたら、端末の間で同期される保存先(iCloud キーチェーン、Google パスワードマネージャー、パスワード管理アプリなど)を選んでください。1 台の端末をなくしても、ほかの端末からログインできます。
- Passkey をすべてなくすと、ログインできなくなります。 セットアップは自動では開き直せません(最初の管理者の作成を参照)。管理者を取り戻すには、D1 データベースを手で書き換えます。手順はPasskey が使えないとき、失くしたときを見てください。
- 2 人目の管理者は追加できません。 セットアップが完了したあとは、Passkey を持たないユーザーは新しく Passkey を登録できないからです。
何をするか
- Better Auth と Passkey プラグインを使います。 管理画面と管理 API は Better Auth とその Passkey プラグインを使います。ユーザー、セッション、Passkey は、同じ D1 データベースに保存します。
- ほかの入り口はありません。 メールとパスワードでのログインは無効です。開発用の抜け道、既定のアカウント、共有のキーもありません。
- 本人確認が必要です。 Passkey の登録とログインには、PIN や生体認証による本人確認が必要です。
- IP アドレスを記録しません。 Better Auth はセッションに IP アドレスを記録しません。
- ログインには回数制限があります。 問い合わせフォームと同じ Rate Limiting binding で、ハッシュ化した IP アドレスごとに数えます。その binding か、IP アドレスのハッシュに使う鍵(
APP_SECRET)がなければ、ログインは使えません。 https://でだけ動きます。 ログイン、セットアップ、管理画面、管理 API が対象です。wrangler devのために、localhostでは平文の HTTP も使えます。セキュリティモデルを参照してください。- Cloudflare Access を前に置けます。
/admin、/admin/*、/api/admin/*、/api/setup、/api/auth/*が対象です。ACCESS_AUDとACCESS_TEAM_DOMAINを設定すれば、Worker も Access のトークンを確認します。Access を使っても、Passkey でのログインは必要です。セキュリティモデルを参照してください。 - Passkey より前のセッションは使えません。 管理 API は、最初の Passkey より前に作られたセッションを受け付けません。受け付けるのは、Passkey でのログインか、最初の Passkey の登録で作られたセッションです。最初の管理者の作成を参照してください。
コードでは
Better Auth は emailAndPassword: { enabled: false } で設定し、IP アドレスの記録も切っています(advanced.ipAddress.disableIpTracking)。
Passkey プラグインには、リクエストのホスト名を relying party ID として渡し、本人確認を必須と指定します。ただしプラグインは、登録の検証で本人確認を強制しません。そのため、登録のフック(registration.afterVerification)で確かめます。
passkey({
rpID: new URL(origin).hostname,
rpName: 'goodnight-web',
origin,
authenticatorSelection: {
residentKey: 'required',
userVerification: 'required',
},
// The plugin verifies with requireUserVerification: false, so enforce
// user verification (PIN or biometrics) here.
registration: {
afterVerification: async ({ ctx, verification, user }) => {
if (!verification.registrationInfo?.userVerified) {
throw new APIError('UNAUTHORIZED', {
message: 'user verification required',
});
}Better Auth のインスタンスは、必要になったときに Worker の binding から作り、同じ isolate(Worker の実行環境)の後のリクエストでも使い回します。作るのに CPU を約 5 ms 使うので、毎回作ると無料プランの上限の半分を使ってしまいます。キャッシュは env オブジェクトとオリジンをキーにするので、binding が変われば新しいインスタンスを作ります。サイトのオリジンは 1 つか 2 つなので、env ごとに保持するのは最大 8 個までで、それを超えると最後に使われてから最も時間がたったものを捨てます。
export const MAX_AUTH_INSTANCES_PER_ENV = 8;
// Building betterAuth() costs about 5 ms of CPU, close to the free plan's
// 10 ms limit, so instances are reused within an isolate. They are keyed by
// the env object (a new env means new bindings or secrets) and by origin
// (passkeys are bound to the hostname). See docs/decisions/0001. A Map keeps
// insertion order, so re-inserting on use makes the first key the least
// recently used.
const cache = new WeakMap<GoodnightEnv, Map<string, GoodnightAuth>>();export const getAuth = (env: GoodnightEnv, origin: string): GoodnightAuth => {
let byOrigin = cache.get(env);
if (!byOrigin) {
byOrigin = new Map();
cache.set(env, byOrigin);
}
let auth = byOrigin.get(origin);
if (auth) {
byOrigin.delete(origin);
} else {
auth = buildAuth(env, origin);
for (const oldest of byOrigin.keys()) {
if (byOrigin.size < MAX_AUTH_INSTANCES_PER_ENV) break;
byOrigin.delete(oldest);
}
}
byOrigin.set(origin, auth);
return auth;
};なぜ Passkey か
- 盗まれたり使い回されたりするパスワードがありません。 Passkey はドメインに結びつくので、別のドメインのフィッシングサイトでは使えません。
- 無料プランの CPU の上限に収まります。 無料プランの Worker が 1 回のリクエストで使える CPU 時間は 10 ms ほどです。パスワードのハッシュ(scrypt)はわざと遅く作られていて、Workers 上で Better Auth のパスワード処理がこの上限を超えるという報告があります(better-auth#8860)。Passkey のログインは署名を確かめるだけなので速く済みます。
- ほかに動かすものがいりません。 Passkey には、メール送信のサービスも SMS のサービスも必要ありません。