Flutter×パスキー(FIDO2/WebAuthn)で作る金融アプリ認証:実装設計と落とし穴

モダンなスマホアプリ技術と金融業界ニーズの「交差点」をピンポイントで掘り下げます。本記事のテーマは「Flutter×パスキー(FIDO2/WebAuthn)」。パスワードレスでフィッシング耐性が高い認証を、金融レベルの要求(安全・復旧・監査)に合わせてどう実装するかを、コード+背景説明でまとめます。

1. 背景と要件整理:なぜパスキーか、金融ならではの制約

1-1. パスキーの基本(FIDO2/WebAuthn)と金融での利点

パスキーは、FIDO2(CTAP2)/WebAuthn に基づく公開鍵認証です。ユーザー端末が鍵ペア(公開鍵/秘密鍵)を生成し、秘密鍵は端末の安全領域(Secure Enclave/StrongBox 等)から外に出ません。ログイン時はサーバが送ったチャレンジに署名し、公開鍵で検証します。

金融の視点では、フィッシング耐性(ドメイン紐付き)・漏えい耐性(サーバ側は公開鍵だけを保管)・生体認証との親和性(指紋/顔)という利点が大きいです。パスワード再利用やSMSワンタイムの取りこぼしを減らし、UXと安全性を同時に向上できます。

一方で、端末紐付きであるがゆえに「機種変更や紛失時の復旧設計」が必須です。クラウド連携(iCloud Keychain/Google Password Manager)や口座連携によるリカバリーポリシーを合わせて考えます。

1-2. 金融特有の要件:監査・障害時手順・KYCと整合

金融アプリは、単にログインできればOKではありません。監査証跡(いつ・誰が・どこで)障害時のフォールバック(リカバリー)KYC(本人確認)との紐付けが求められます。パスキー作成/使用/削除といった各イベントに追跡IDを付与し、サーバへ改ざん不可なログとして送ります。

また、認証と認可(どこまで操作できるか)は別物です。高リスク操作(送金限度額の変更など)は、パスキーによる再認証(step-up)や生体確認を追加します。

最後に、PIIの最小化が原則です。端末やログに個人情報を残さず、必要ならトークン化/マスキングを実施します。

1-3. Flutter採用の判断軸(ネイティブ連携と運用)

Flutterはクロスプラットフォームで開発速度に優れますが、パスキーは各OSのネイティブAPI(Android Credential Manager / iOS ASAuthorizationController)と密接です。プラットフォームチャネルでネイティブを叩く or パッケージを活用する方針を決めましょう。

銀行レベルの品質を狙うなら、認証周辺はネイティブ実装を薄く持つのが現実解です。OSアップデートへの追随、UIの一貫性、生体認証エラー時の分岐など、細部はネイティブで扱う方が安全です。

なお、監査・可観測性(ログ/メトリクス/クラッシュレポート)は初期から仕込んでおきます。後から入れると追跡が穴だらけになります。

2. 設計と実装(Flutter→ネイティブ連携):登録・認証フロー

2-1. フロー全体像:登録(create)と認証(get)

パスキーは大きく「登録(credential.create)」と「認証(credential.get)」。サーバはWebAuthn仕様に沿ってチャレンジ/オプションを生成し、アプリがOSの認証UIを呼び出します。結果(Attestation/Assertion)をサーバに送り、検証して完了します。

ここでの実装の肝は、Flutter↔ネイティブの責務分離です。Dart側は「サーバ通信と状態管理」、ネイティブ側は「OSの認証UIを呼ぶ」役割に分けます。

次の例では、MethodChannelで Android/iOS のパスキーUIを叩く土台を示します。

// lib/passkey_bridge.dart
import 'package:flutter/services.dart';

class PasskeyBridge {
  static const _ch = MethodChannel('com.example.passkey');

  // 登録: サーバから受け取った PublicKeyCredentialCreationOptions(JSON) をそのまま渡す
  static Future<Map> createCredential(Map creationOptionsJson) async {
    final result = await _ch.invokeMethod('create', creationOptionsJson);
    return Map.from(result as Map);
  }

  // 認証: PublicKeyCredentialRequestOptions(JSON)
  static Future<Map> getAssertion(Map requestOptionsJson) async {
    final result = await _ch.invokeMethod('get', requestOptionsJson);
    return Map.from(result as Map);
  }
}

意図:Dart側はJSONそのままを受け渡し、ネイティブでOS APIに合わせて変換します。Flutter側は「ネットワーク/状態」、ネイティブは「UI/セキュア領域」と分けることで責務が明確になります。

2-2. Android:Credential Manager 経由でパスキーUIを起動

Androidでは Credential Manager を使うのが現行の推奨です。パスキー(PublicKeyCredential)と従来のパスワード/Passkey連携が統一され、ユーザー体験が一貫します。

// android/app/src/main/.../PasskeyPlugin.kt(概略)
class PasskeyPlugin(private val activity: Activity) : MethodChannel.MethodCallHandler {
  override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
    when (call.method) {
      "create" -> createPasskey(call.arguments as Map<*, *>, result)
      "get"    -> getAssertion(call.arguments as Map<*, *>, result)
      else     -> result.notImplemented()
    }
  }

  private fun createPasskey(opts: Map<*, *>, result: MethodChannel.Result) {
    // opts(JSON) → CreatePublicKeyCredentialRequest に変換(省略)
    // CredentialManager に渡して OS の生体認証UIを起動
    // 成功時は attestationObject/clientDataJSON 等をJSONで返す
  }

  private fun getAssertion(opts: Map<*, *>, result: MethodChannel.Result) {
    // opts(JSON) → GetPublicKeyCredentialOption に変換(省略)
    // 成功時は authenticatorData/signature 等をJSONで返す
  }
}

意図:OSのUIで生体認証を出し、結果バイナリ(ArrayBuffer)をBase64URLでDart側へ返します。署名対象のチャレンジは毎回ランダムで、origin/rpIdが正しいかはサーバで検証します。

なお、Android 14+ の Device Credential(端末PIN/パターン)やStrongBox(耐タンパ)対応の有無は端末次第なので、機能検出して分岐します。

2-3. iOS:ASAuthorizationControllerでPasskeysを扱う

iOSでは ASAuthorizationControllerASAuthorizationPlatformPublicKeyCredentialProvider を使います。iCloud Keychain 同期のパスキー(同期型)にも対応します。

// ios/Runner/PasskeyPlugin.swift(概略)
class PasskeyPlugin: NSObject, FlutterPlugin {
  static func register(with registrar: FlutterPluginRegistrar) { /* ... */ }

  func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
    switch call.method {
    case "create":
      /* ASAuthorizationController を起動して登録 */
      break
    case "get":
      /* 同上で認証 */
      break
    default:
      result(FlutterMethodNotImplemented)
    }
  }
}

意図:iOSはユーザー体験(生体UI・キーチェーン同期)が完成度高く、誤操作に強いのが利点。サーバは rpId(ドメイン)や clientDataJSON.typewebauthn.get / webauthn.create であること等を厳格に検証します。

いずれのOSでも、サーバ側検証(署名/カウンター/有効な信頼ルート)が最重要です。クライアント側は「正しいメッセージを作って結果を返す」責務に留めます。

3. 秘密管理とセッション:トークン/キー/ストレージの扱い

3-1. アクセストークンは「短寿命+更新は再認証寄り」

パスキーはログイン手段です。ログイン後のAPIアクセスはアクセストークン(短寿命)で行い、更新は「静的な長寿命リフレッシュ」よりも、再認証(step-up)寄りに設計すると安全です。

金融では特に、トークン窃取の影響範囲を狭くする設計が要。トークンはメモリ優先(アプリ終了で破棄)、保存が必要なら暗号化ストレージ+生体/端末認証ゲートで守ります。

重要操作は都度生体を促すのが現実的な落としどころです(利便と安全のバランス)。

3-2. セキュアストレージ(Keychain/Keystore)とFlutter

Flutterでは「セキュアストレージ系パッケージ」を使ってKeychain/Keystoreへ保存するのが一般的です。保存するのは最小限(例:認可スコープや表示用ニックネームなど)。PIIや秘密鍵は保存しない方針が原則です。

保存・参照時は常に生体/端末認証のプロンプト(BiometricPrompt/LocalAuthentication)をはさみ、肩越しのぞき見・置き忘れ対策を行います。

また、スクリーンショット抑止やアプリスイッチャのサムネイルをマスクして、視覚的な情報漏えいを抑制します。

3-3. セッション固定化とCSRF相当の対策

ネイティブアプリでも、セッション固定化(攻撃者が既存セッションを使い回す)には注意が必要です。ログイン直後にセッションID再発行(トークン再払い出し)を行い、デバイス紐付け情報(端末ID/証明)と相関させます。

API呼び出しではリクエストID/nonceを付与し、リプレイを検知・拒否。重要操作にはタイムバウンドの承認(時刻署名)を取り入れます。

さらに、デバイス越えの並行ログインや、通常と異なる地理/端末指紋を検知したらリスクベース再認証へ誘導します。

4. フォールバック/リカバリー:機種変更・紛失・オフライン

4-1. 機種変更フロー:同期型パスキーとKYC再確認

iCloud/Google Password Managerの同期型パスキーがあれば、新端末でも引き継ぎは比較的スムーズです。ただし、ハイリスク操作は新端末では追加検証(本人確認書類/低額トランザクション)を行います。

同期がない/オフのユーザー向けに、窓口での再登録郵送確認などのバックアップ導線も定義しておきます(全ユーザーがクラウド同期を有効にしているわけではない)。

アプリ側では「新端末登録の手順」を画面で分かりやすく案内し、必要な提出書類や所要時間を明確に伝えます。

4-2. 紛失・盗難:遠隔失効と端末検知

紛失報告を受けたら、該当端末のセッション失効と、端末紐付けレコードの無効化を速やかに行います。ユーザーはWeb管理画面から端末一覧を確認し、自分で失効できるのが望ましいです。

端末復帰後に再ログインが必要なこと、残高や個人情報が保護されていることを明確にガイダンスします。

併せて、ログ基盤では異常な地理・時刻・操作パターンをルール化し、アラート/ブロックに繋げます。

4-3. オフライン時の扱い:一時キャッシュと強制同期

認証そのものはオンライン前提ですが、残高のサマリ表示などは一時キャッシュでUXを保てます。ただし、表示は明確に「最終同期時刻」を添え、取引操作はブロックします。

オンライン復帰時に強制同期を行い、キャッシュは破棄。書き込み操作(振込予約等)は冪等キーで安全に再送します。

エラーUIは「待てば解決するのか/操作が必要なのか」をはっきり提示して、誤操作を防ぎます。

5. テスト・監査・運用:壊れにくさと説明可能性

5-1. 認証E2Eテスト:モック+実機の二段構え

パスキーはOS UIを伴うため、完全自動のUIテストは難易度が高いです。まずはサーバ側のWebAuthn検証をモックし、Dart側の状態機械をテスト。次に実機での手動回帰を手順書化します。

Android Emulator/iOS Simulator でも基本動作検証は可能ですが、生体認証の挙動やキーチェーン同期は実機差が出るため、代表端末での検証計画が必要です。

異常系(キャンセル/生体失敗/時間切れ/端末ロック)を網羅し、ユーザーが迷わない文言/遷移を固めます。

5-2. 観測と監査:イベントスキーマとPII最小化

「作成/認証/失効/再登録」ごとにイベントスキーマを定義し、traceId・端末種別・OS・アプリ版・rpIdを付与。PIIは含めない/トークン化を徹底します。

異常率(失敗/キャンセル/再試行)を可視化し、サポート/運用の改善に回します。フィッシング類似ドメインが増えていないか、サーバ側で継続監視します。

ログは改ざん困難なストレージにエクスポートし、保持期間/削除手順をガバナンスとして定義します。

5-3. アップデート追随:OS/SDKの変更に備える

認証周りはOS更新の影響が大きい領域です。Flutter本体とネイティブ層(Credential Manager/ASAuthorization)の更新チェックをCIで定期実行し、リリースノート監視→検証→段階配信のルーチンを作ります。

互換性が崩れた時にすぐ戻せるよう、Feature Flagで旧方式(パスワード+OTP等)を温存し、緊急時に切替できるようにしておくと安心です。

セキュリティ勧告(依存の脆弱性)にも即応できるよう、依存棚卸し(SBOM)を持ち、更新を自動PR化しておくと運用が楽になります。

コード断片(Flutter側の使い方例)

実際の呼び出しは以下のようになります。creationOptionsJson/requestOptionsJson はサーバから受け取ったWebAuthnオプションのJSONです(base64urlでチャレンジ/IDをエンコード)。

final attestation = await PasskeyBridge.createCredential(creationOptionsJson);
// → { id, rawId, response: { attestationObject, clientDataJSON }, type }

final assertion = await PasskeyBridge.getAssertion(requestOptionsJson);
// → { id, rawId, response: { authenticatorData, clientDataJSON, signature, userHandle }, type }
// これらをそのままサーバへPOST。検証は必ずサーバで実施する。

意図:クライアントは中継に徹し、検証はサーバ。ここを取り違えると、端末改ざんに弱くなります。Dart側では例外時のUI(再試行/別手段)を丁寧に設計しましょう。

まとめ

Flutter×パスキーは、金融アプリの安全とUXを同時に引き上げる有力解です。要点は、責務の分離(Flutter=状態/通信、ネイティブ=認証UI)短寿命トークンと最小保存障害時フォールバック、そしてテスト/監査/追随運用。小さな画面から導入して、実運用で磨き込んでいきましょう。

参考URL

採用情報 長谷川 横バージョン
SHARE