本記事は「Medley Summer Tech Blog Relay」の 5日目の記事です。
1. はじめに
メドレーのエンジニアのSongです。ジョブメドレーアカデミーという介護・障がい福祉・看護などヘルスケア領域向けのオンライン動画研修サービスの開発に携わっています。
ジョブメドレーアカデミーでは、長らくID・パスワード認証を採用してきましたが、ログイン率が伸び悩んでいました。その原因のひとつが「利用者のID・パスワード忘れ」でした。
これを解決するために、今回パスキーを導入することにしました。
(※ パスキー作成を促すモーダル画面。左がモバイルアプリ、右がPC)パスキーはセキュリティ強化目的で注目されることが多いですが、今回着目したのは 「ID・パスワードを覚えていなくてもログインできる」というパスキーの利便性 です。
特にジョブメドレーアカデミーの利用者は、事業所に設置された共有のPCからも研修を受講します。共有の端末にはID・パスワードを保存するべきではないので、毎回利用者自身でID・パスワードを入力してログインする必要がありましたが、ID・パスワード忘れによって受講ができないという事象も発生していました。この問題も、利用者個人のスマートフォンでパスキーを登録し、共有PCにこのパスキーを使ってログインすれば、解決することができます。
導入から約1ヶ月半。パスキー登録者は2.6万人を超え、パスキーログイン試行のうち、Webでは98.4%が、モバイルアプリでは95.9%がログインに成功しています。 1
この記事では、「パスキーログインの成功率を上げる」ために具体的に何をやったか、設計や実装の観点で書いていきます。
2. 成功率を上げるために考えた2つの観点
「ログインの成功率を上げる」ために行ったことを、2つの観点に分けてみました。
-
観点 A:ログインのハードルを下げる
入力をなくし、どの端末からでもパスキーを使えるようにする。 -
観点 B:ログインを失敗させない・フォローする
登録できたのにログインできないなど、パスキーが使えない状況を減らす。また、パスキー登録・ログインできないケースに対して、原因・対策を伝える。
3. システム全体の構成
本題へ入る前に、今回の構成図(流れ)と使用ライブラリです。
構成図
Webとモバイルアプリは、サーバー処理を共通化しています。
(※ AIを使用して生成)
※ challengeとは、サーバーが毎回発行するランダム値。署名対象にしてリプレイを防ぎ、検証後には使い捨てます。
※ RP IDとは、Relying Party IDのことで、そのパスキーが「どのサービスのものか」を表すドメインです。
※ createとgetとは、WebAuthnで定義されているメソッドです。WebAuthnについてはこちらを参照してください。
※ Credentialとはパスキー(鍵ペアを含む資格情報)のことを指し、これを識別できる一意の値がCredential Idです。
使用ライブラリ
| 層 | 使用ライブラリ |
|---|---|
| Web | @simplewebauthn/browser (v13.2.2) |
| iOS / Android アプリ | react-native-passkeys(v0.4.1)※クロスプラットフォーム |
| API | @simplewebauthn/server(v13.2.2) |
4. 観点 A:ログインのハードルを下げる
①個人を特定する情報を入力させない(usernamelessの採用)
今回は、「利用者のログインID・パスワード忘れ」を防ぐのが一番の目的のため、利用者に何も入力させないusernameless方式を採用しました。
usernamelessを実現するには、パスキーをDiscoverable Credentialにする必要があります。
パスキーログインする時、サーバー側で作成したオプションを認証器に渡し、認証器側でRP ID(=パスキーを使用しているサービスのドメイン)に対応するCredentialを選択し、その秘密鍵を使って認証レスポンスに署名します。
Discoverable Credentialとは、この時に、サーバーから認証器に秘密鍵を識別するCredential IDを渡さなくても、認証器側がRP IDに対応する秘密鍵を発見できるやり方のことです。
利用者の画面には、RP IDに対応するパスキーが表示されます(複数ある場合は、複数表示)。利用者が選択すると、そのCredential IDと userHandle(=パスキー用のユーザー識別子)が認証器から返され、サーバー側はこれらを使ってパスキーおよび利用者を特定し、その後の検証フローで使用します。Credential IDはパスキーごとにユニークなので、1ユーザーが複数端末のパスキーを登録することも可能です。
// サーバーサイドの認証時のオプションの生成(usernameless方式)
const options = await generateAuthenticationOptions({
rpID: ACADEMY_DOMAIN,
userVerification: 'required',
// ここで allowCredentials を指定しない
});
※ allowCredentials: ユーザー識別子を先に入力する方式(identifier-first)では、ユーザーが入力した情報から、そのユーザーのパスキーのCredential IDを特定し、allowCredentialsを指定することで、認証器側に、対応する秘密鍵を特定させる。
パスキー登録時のオプション生成で正しく指定しないとDiscoverable Credentialにはなりません。
- residentKeyで
requiredを指定する- Discoverable Credential対応端末の時のみパスキー生成をする(古い端末だと対応していない)
- user IDなどユーザー情報を指定する
- ユーザー情報を渡すことで、ログイン時に認証器側からuserHandleが返される
// サーバーサイドのパスキー登録時のオプション生成(usernameless方式)
const options = await generateRegistrationOptions({
rpName: 'ジョブメドレーアカデミー',
rpID: ACADEMY_DOMAIN,
userID: passkeyUserId, // Userを識別できるID(バイト列)
userName: user.loginId,
userDisplayName: user.name,
attestationType: 'none',
excludeCredentials, // 既存Credentialを列挙し、同じCredentialを認識できる認証器への再登録を防ぐ
authenticatorSelection: {
residentKey: 'required', // Discoverable Credentialを要求
userVerification: 'required', // 生体認証・PINなどで本人確認を要求
},
});
userHandleには業務上のuser ID(主キー)ではなく、パスキー専用に生成した別IDのpasskey_user_idを使っています。理由は以下の通りです。
- 認証器によってはuser IDが露出する場合があるので、念のため、見えてもいい値にする
- 認証時には、返却されたuserHandleがユーザーの現在のpasskey_user_idと一致することも検証しています。万一アカウントが乗っ取られたとき、passkey_user_idをローテーションすることで、そのユーザーに紐づく既存パスキーを利用できなくできます。
②Web・iOS・Androidで同じパスキーを利用できるようにする。
RP IDを共通化することで、同じパスキーをWeb・iOS・Android間共通で使えるようにしました。iOSではAssociated Domain/AASA、AndroidではDigital Asset Links(assetlinks.json)を設定することで、アプリとRPを関連付けています。(※ただし、端末をまたいでパスキーを利用できる範囲そのものは、OS・パスキープロバイダー・対応ブラウザに依存します。)
具体的には以下すべての箇所でドメインが一致している必要があります。
- RP ID(サーバーサイドのドメイン)
- クライアントサイドのドメイン
- モバイルアプリの設定ファイルで指定するAssociated Domains(iOSのみ)
- Webの.well-known配下にホストする以下の検証用ファイルの配置先ドメイン
- iOS:
apple-app-site-association(AASA) - Android:
assetlinks.json
- iOS:
一方で、サーバーサイドでパスキーの検証に使うoriginはプラットフォームごとに異なります。
- WebとiOSアプリ: httpsオリジン
- Androidアプリ:
android:apk-key-hash:<署名証明書のハッシュ>という専用のoriginになる
認証器から送られてくる「秘密鍵で生成した署名」を、サーバーサイドで登録済みの公開鍵を使って検証する際に、許可するオリジンをexpectedOriginに指定する必要があるので、上記すべてを指定する必要があります。
③PCではQRコードを表示して、スマホでパスキー登録をする
冒頭のジョブメドレーアカデミー特有の課題、「共有PCでのID・パスワード忘れ」の対応策です。
共有PCの場合、端末にパスキーを保存してしまうと、次に別の利用者がその端末を使ったときに、前の人のアカウントでログインできてしまう可能性もあるので、本人情報を端末に登録させたくありません。
これを回避するのが、QRコードを使ったクロスデバイス認証です。
(※ AIによる生成のイメージ画像)
PC利用者は、パスキー登録時にQRコードが表示されるので、自分のスマートフォンの認証器(iCloudキーチェーンやAppleパスワード、Googleパスワードマネージャなど)にパスキーを登録しておくことで、次回以降、スマホ内のパスキーを使ってPCのログインを完了できます。
実装上は、PC・タブレットのブラウザからパスキー登録を開始した場合、authenticatorAttachment: 'cross-platform'を指定します。
// 登録時のオプション生成時に指定
authenticatorSelection: {
residentKey: 'required',
userVerification: 'required',
// PC・タブレットのブラウザは cross-platform(QRコード表示)
// スマホは platform(同一端末内の操作)
authenticatorAttachment: isPcTabletBrowser ? 'cross-platform' : 'platform',
},
cross-platformはQRコードを必ず表示するものではなく、端末によってはセキュリティキーが優先されます。
例えば、モバイルアプリのiPadではクロスデバイス認証の採用を断念しました。
iPadのアプリでcross-platformを指定すると、実機で確認した時にQRコードではなくセキュリティキー登録の画面が表示されたためです。
5. 観点 B:ログインを失敗させない・フォローする
①「登録できるのにログインできない」を防ぐ
観点Aで、Discoverable Credentialを実現するために、登録時のオプション生成時にresidentKey: 'required'を指定すると書きました。
residentKeyには、requiredの他に、preferredとdiscouragedが指定できるのですが、当初、preferredを選択していました。preferredはDiscoverable Credentialにできない端末の場合は、Non-Discoverable Credentialなパスキーを生成してくれるので、フォールバックになって良さそうに見えたためです。
しかし、今回はusernameless方式を採用しているので、Non-Discoverable Credentialなパスキーを登録しても、Non-Discoverable Credentialを認証に使うには、サーバーからCredential IDをallowCredentialsとして渡す必要があります。今回はallowCredentialsを指定しないので、そのCredentialを認証器が発見できず、ログイン時に利用できません。
つまり、実装上は一見フォールバック的な挙動をしてくれているように見えていたのですが、実際にユーザーが感じる挙動は「パスキーを登録できたはずなのにログインできない!!!」という最悪の見え方になってしまいます。
usernameless方式にするなら、すべてresidentKey: requiredに揃えて、対象外の端末はパスキー登録自体をさせないようにした方が良いです。
②モバイルアプリは、検証ファイルを取得して検証が成功しないとパスキーが使えない
モバイルアプリではOSやCredential Managerなどのプラットフォーム機構がWebの.well-known配下にある検証ファイル(AASA・assetlinks.json)を取得し、アプリとRP IDの関連付けを検証します。検証に成功しないとパスキー登録・ログインはできません。
検証ファイルは、OSがバックグラウンドで非同期に取得し、RP IDを検証しますが、「非同期検証」の性質上、インストール直後や通信状況が悪いときに一過性で失敗することがあるため、エラー時には「時間をおいて再度お試しください」という案内をする必要があります。
iOSの場合、AppleのCDNを経由するため、ファイルを配置してから実際に反映されるまでにタイムラグがあります。そのため、実際のリリースではAASAの取得・キャッシュに時間がかかるケースを考慮し、アプリリリースより前にファイルを公開しました。
③パスキー対象外の端末は非対応と伝える
ログイン導線でよくわからない表示が出たり、逆に何も出ないことは不安につながるので、端末の状態を事前にチェックして、非対応の旨を伝えました。
- 利用ライブラリと実機検証の結果から、以下をパスキー機能の対象外としました。
Android 8以下iOS 15以下
-
WebAuthn側の状態を元に対象外を判定しました。
- WebAuthnに対応している端末か
- 生体認証や端末のPINなどによるユーザー検証が可能か
社内の実機で一通りテストしたところ、対象バージョンなのにも関わらず、パスキーが使えずにNotSupported エラーを返す端末がありました。他社のパスキーは使えるので、ログを比較していくと、Androidのネイティブモジュールのバージョンが古く、ライブラリのバージョンを上げると解消しました。
④失敗時にネクストアクションを提示する
ログインが失敗したとき、利用者が「もう一度試す」「ID・パスワードによるログインに切り替える」といった正しいネクストアクションを取れるように、正しくエラーハンドリングする必要がありました。
ここでは、分かりにくかったクライアント側での整理について言及します。
Web
WebAuthnではエラー定義が正しくされていたので、以下の対応を入れました。
-
NotAllowedError:ユーザーが途中でキャンセルした可能性が高いので、何も出さない(※ 環境起因などの複数原因をまとめたcatch-allだが、観測では大半がユーザーキャンセルなので、画面へのエラー表示は抑制した) -
InvalidStateError:既に登録済みのパスキー(excludeCredentialsに指定したCredentialを認証器が認識した場合)なので、「このパスキーは登録済みです」と伝え、再登録させない
※ WebAuthnで定義されているエラー
- 登録:https://www.w3.org/TR/webauthn-3/#sctn-create-request-exceptions
- ログイン:https://www.w3.org/TR/webauthn-3/#sctn-get-request-exceptions
アプリ
一方で、モバイルアプリは、iOS・Androidで以下のように英語のメッセージ文字列が返ってきます。
- iOS 例:
ASAuthorizationError 1006/Stolen Device Protection is enabled... - Android 例:
DomError: ConstraintError - Screen lock is missing./[50160] Cannot find an eligible account.
そこで、エラーを正規化して、エラーハンドリングしました。
type PasskeyErrorResolution =
| { action: 'silent' } // ユーザーキャンセル等。トーストも出さない
| { action: 'notify'; message: string } // 想定内エラー。ネクストアクションの提示
| { action: 'fallback'; report: boolean }; // Sentryへ通知
例:
- ドメイン紐付けが未反映・トークン取得失敗: エラーにせず「通信環境を確認し、時間をおいて再度お試しください」と伝える
- 画面ロック・生体認証が未設定: 「端末に画面ロック・生体認証を設定してください」と端末設定を促す
- 使えるパスキーが無い(未登録)・盗難デバイスの保護:「ID・パスワードでログインしてください」と別の手段を伝える
6. 結果
上記に加え、パスキー未登録の利用者にパスキー登録を促すモーダルを出して、パスキー利用者を増やしていきました。
現状、導入から約1ヶ月半経過しましたが、
- パスキー登録者は2.6万人超
- パスキーログイン試行のうち、Webでは98.4%が、モバイルアプリでは95.9%がログインに成功 1
という結果になり、「利用者のID・パスワード忘れによるログインできない」という問題の解消の手助けになっています。
Medley Summer Tech Blog Relay 6日目の記事は山田さんです!
明日もお楽しみに!✨
参考



