実装・SealGate
既存のログインにパスキーを足す手順:パスワードを残したまま始める
10 分で読めます
この記事の要点
- パスキーは既存のログインを置き換えずに、ログインの後の「登録」と、ログイン画面の「もう 1 つの入口」として足せます。
- 先に決めるのは RP ID、登録を受け付ける条件、復旧の方法の 3 つです。
- パスワードはすぐに消さず、パスキーを持つ利用者が増えてから期限を決めて閉じます。
既存のログインにパスキーを足すときは、パスワードのログインを残したまま、(1) ログイン済みの利用者にパスキーを登録してもらい、(2) ログイン画面にパスキーの入口を足し、(3) 端末の一覧と復旧を用意する、の順に進めます。最初に RP ID (鍵を結び付けるドメイン) と、登録を受け付ける条件を決めておけば、作り直しは要りません。パスワードを閉じるのは、パスキーを持つ利用者が増えてからです。
全体の流れ
| 段階 | やること | 利用者から見えるもの |
|---|---|---|
| 0. 準備 | RP ID とオリジンを決める。対応状況を検出する | なし |
| 1. 登録 | ログインの後にパスキーの登録を勧める | 「次回から指紋でログインできます」 |
| 2. ログイン | ボタンと入力欄の自動入力でパスキーを使えるようにする | ログイン画面の「パスキーでログイン」 |
| 3. 管理 | 登録したパスキーの一覧・名前の変更・削除 | アカウント設定の「パスキー」 |
| 4. 復旧 | 端末をなくした人の再登録 | 「ログインできない場合」 |
| 5. 併存 | パスワードを閉じる期限を決める | パスワード欄が出なくなる |
0. 準備:RP ID とオリジンを決める
RP ID は、パスキーが結び付くドメインです。ログイン画面のドメインか、その親のドメインを選びます。たとえばログイン画面が login.example.jp で、www.example.jp でも同じパスキーを使いたいなら、RP ID は example.jp にします。
RP ID は後から変えられないものとして決めてください。 登録済みのパスキーは登録したときの RP ID にしか使えないため、変えると全員のパスキーが使えなくなります。
あわせて、対応状況の検出を入れます。window.PublicKeyCredential が無いブラウザではパスキーの UI を出さず、これまでどおりパスワードのログインだけを見せます。入力欄の自動入力に使う conditional mediation は、PublicKeyCredential.isConditionalMediationAvailable() で確かめられます。
1. ログインの後に登録してもらう
最初の登録は、ログインに成功した直後か、アカウント設定の画面で勧めます。passkeys.dev の導入の手引き (Bootstrapping) も、既存の方法 (必要に応じて多要素認証を含む) で十分に強く認証できたことを確かめてから、パスキーの作成を勧めるよう書いています。
ここで大事なのは次の 3 点です。
- 登録の入口を守る。 パスワードだけで入ったセッションにいつまでも登録を許すと、盗まれたパスワードで入った第三者が自分のパスキーを足せます。ログインした直後に限る、既存の多要素認証を済ませた後に限る、時間が経ったセッションでは再認証を求める、のいずれかを入れます。
- 利用者 ID に個人情報を入れない。 WebAuthn の仕様は、
user.id(user handle) に利用者名やメールアドレスのような個人を特定する情報を入れてはならないとし、64 バイトのランダムな値を推奨しています。 - 同じ認証器での二重登録を防ぐ。 既に登録したパスキーを
excludeCredentialsに入れて渡すと、認証器が同じ利用者の 2 本目の作成を断ります。
2. ログイン画面にパスキーの入口を足す
ログイン画面には 2 つの入口を足せます。
- 「パスキーでログイン」のボタン。
navigator.credentials.get()を呼び、端末に保存されたパスキーから利用者に選んでもらいます。allowCredentialsを空にすると、利用者名を入れずにログインできます (discoverable credential)。 - ユーザー名の入力欄の自動入力。 入力欄に
autocomplete="username webauthn"を付け、画面を開いたときにmediation: 'conditional'でnavigator.credentials.get()を呼んでおくと、入力欄の候補にパスキーが並びます。パスワードを選んだ人は、そのままパスワードで進めます。
どちらの場合も、パスワードの入力欄は残します。パスキーを持っていない利用者や、パスキーの無い端末からの利用者がいるためです。
3. 登録したパスキーを管理できるようにする
アカウント設定に、登録したパスキーの一覧を出します。最低限、次の 3 つが要ります。
- 一覧: 名前 (「iPhone」「仕事の PC」など)、登録日、最後に使った日
- 名前の変更: 利用者が見分けられるようにする
- 削除: なくした端末のパスキーを無効にする
同期パスキーかどうか (WebAuthn の BE / BS フラグ) も分かると、「この端末にしかパスキーが無い利用者」に 2 本目の登録を勧める判断に使えます。
4. 復旧を決めてから公開する
端末をなくした・同期を使っていない利用者が、パスキー以外の方法で本人であることを示し、パスキーを登録し直す流れを用意します。本人確認の方法 (登録済みのメールと別の要素、窓口での確認など) はサービスごとに違いますが、公開の前に決めておく必要があります。詳しくは パスキーの復旧の設計 で扱います。
5. パスワードとの併存と、閉じる期限
最初はパスワードとパスキーを併存させます。パスキーを持つ利用者が増えたら、期限を決めて、パスキーを持つ人にはパスワードの入力欄を出さない・パスワードでのログインを受け付けない、と段階的に進めます。利用者に期限を事前に知らせ、復旧の流れが動いていることを確かめてから閉じます。
SealGate で実装する場合
SealGate では、上の段階を次の API と SDK で組みます。API キーは開発者のサーバーにだけ置き、ブラウザの SDK は開発者のサーバーの URL (optionsUrl / verifyUrl) を呼びます。
準備
管理画面でプロジェクトを作ると、テスト環境 (rp_id localhost) から始まります。ログインのドメインが決まったら本番環境を足し、rp_id とオリジンを設定します。テスト環境の API キーは sk_test_、本番環境は sk_live_ で始まります。
ブラウザ側
import { SealGate } from 'https://sealgate.jp/sdk/v1.js';
const sg = new SealGate({
optionsUrl: '/api/passkey/options',
verifyUrl: '/api/passkey/verify',
});
if (SealGate.supported()) {
// ログイン済みの画面で
await sg.register({ name: 'この端末' });
}
// ログイン画面のボタン
const result = await sg.login();npm では sealgate-browser (同じ内容、型付き) を使えます。失敗は SealGateError で、code が cancelled (利用者が閉じた・時間切れ)、already_registered (この認証器に既に鍵がある)、unsupported などになります。
サーバー側
SDK は { kind: 'register' | 'login', ... } を POST します。サーバーは kind を見て SealGate の /v1/register/* か /v1/login/* に中継し、ログインの verify が通ったら自分のセッションを発行します。
const BASE = 'https://api.sealgate.jp/v1';
export async function POST(req, { params }) {
const { step } = await params; // 'options' | 'verify'
const body = await req.json();
const user = await currentUser(req); // 自分のログイン状態
const payload = step === 'options'
? body.kind === 'register'
? { external_id: user.id, display_name: user.email, signed_in: true }
: { external_id: body.external_id }
: { challenge_id: body.challenge_id, response: body.response };
const res = await fetch(`${BASE}/${body.kind}/${step}`, {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.SEALGATE_API_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify(payload),
});
const json = await res.json();
if (step === 'verify' && body.kind === 'login' && res.ok) {
await createSession(json.external_id);
}
return Response.json(json, { status: res.status });
}押さえておく点です。
external_idには、メールアドレスではなく、個人を表さない ID (内部の番号やランダムな ID) を使います。SealGate はexternal_idとdisplay_nameを保存するためです。- 既にパスキーを持つ利用者に 2 本目を足すときは、
signed_in: trueかrecovery_tokenが要ります。無ければ 403 (recovery_not_allowed) です。signed_inはブラウザから届いた値ではなく、サーバーのセッションで決めます。登録を受け付ける条件 (直前のログインや再認証) も、このサーバー側で判断します。 - 登録とログインの challenge は 5 分で失効し、1 回しか使えません (
challenge_expired)。
管理・復旧・併存
- 一覧は
GET /v1/users/{external_id}/credentialsで、名前・aaguid・backup_eligible・backed_up・最後に使った日時が返ります。名前の変更はPATCH、1 本の失効はDELETE /v1/credentials/{credential_id}です。 - 復旧は、開発者側で本人確認をした後に
POST /v1/recovery/startを呼び、15 分有効のrecovery_tokenを/v1/register/optionsに渡します。SealGate は本人確認をしません。 - 管理画面の環境の設定で「パスワードでのログインの期限」を決めると、期限を過ぎた後、パスキーを登録した利用者のログインの応答で
password_allowed: falseが返ります。パスワードの入力欄を隠すかの判断に使えます。
API の詳細は ドキュメント、料金は 料金表 をご覧ください。
よくある質問
パスワードのデータベースを移す必要はありますか?
ありません。パスキーは利用者に結び付く別の資格情報として足すもので、パスワードのハッシュはこれまでどおり既存の仕組みで扱います。
最初の登録は、新規登録の画面で求めるべきですか?
既存の利用者に足す場合は、ログインの後の画面で勧めるのが始めやすい形です。新規登録の画面で求める場合も、パスキーを作れない端末の利用者のために別の方法を残します。
テスト環境でも本番と同じドメインが要りますか?
WebAuthn では、RP ID がページのドメインかその親でないと登録できません。手元の開発では localhost を RP ID にした環境を使い、本番のドメインとは分けます。