React Native×Passkeys:FIDO2/WebAuthnでパスワードレス金融認証を実装

0. はじめに

「モダンなアプリ技術 × 金融ニーズ」の交点として、本記事はReact Native(RN)でのパスキー(Passkeys)実装を深堀りします。パスキーはFIDO2/WebAuthnベースの公開鍵認証で、フィッシング耐性が高く、端末の生体認証と親和性が高いのが強み。金融アプリの「安全・利便・監査」の三立を目指す時、第一候補となる方式です。

ただし、WebAuthnはOSネイティブAPIと密接です。RN単体では完結しないため、AndroidはCredential Manager、iOSはASAuthorizationとの連携が必須。この記事では、RNブリッジ設計→ネイティブ呼出→サーバ検証→運用論点の順で、コードと意図をセットで解説します。

1. アーキテクチャと要件整理

1-1. 目的と非機能要件(金融視点)

目的はパスワードレスで安全なログイン体験の実現。金融ではこれに加えて「高リスク操作時の再認証(step-up)」「監査証跡」「障害時のフォールバック」「端末紛失時の失効と復旧」を満たす必要があります。

非機能要件は、短寿命トークンセキュアストレージ最小化ノーキャッシュ設計改ざん困難なログ。UXと安全のバランスは、金融では安全寄りにチューニングします。

端末紐付きという特性上、機種変更/紛失時の設計が品質を左右します。同期パスキー(iCloud/Google PW Manager)活用と、KYC再確認の導線を合わせて用意します。

1-2. RNでの責務分離(ブリッジ戦略)

RN側の責務は状態管理とサーバ通信、ネイティブ側はOSのパスキーUI呼出と結果整形。JSONのPublicKeyCredentialOptionsをそのまま往復させると実装がシンプルです。

ライブラリ選択肢(例:コミュニティ製の passkeys/credential-manager バインディング)もありますが、金融要件ではOS追随・リスク対応のため、自前ブリッジの余地を残しておくのが現実解です。

サーバ検証は必ずサーバで完結(署名・rpId・origin・カウンタ検証)。クライアントは中継に徹します。

2. RNブリッジの骨格(Dart ↔ Native)

2-1. React Native(JS/TS)側:MethodChannelの薄いラッパ

RNでは NativeModules でネイティブへ命令を渡します。JSONのオプションをそのまま受け渡す薄いラッパにして、変換複雑度をネイティブに寄せます。

// src/passkeys.ts
import { NativeModules } from 'react-native';

type Json = Record<string, unknown>;
const { PasskeyModule } = NativeModules;

export const Passkeys = {
  async create(options: Json): Promise<Json> {
    // PublicKeyCredentialCreationOptions(JSON) → attestation 結果(JSON)
    return await PasskeyModule.create(options);
  },
  async get(options: Json): Promise<Json> {
    // PublicKeyCredentialRequestOptions(JSON) → assertion 結果(JSON)
    return await PasskeyModule.get(options);
  },
};

意図:JS側はほぼ配線だけにすることで、OS APIの変化やバイナリ→Base64URL変換などの複雑さをネイティブ層に閉じこめます。エラーハンドリングは「キャンセル」「認証失敗」「タイムアウト」を区別してUXを最適化します。
作成/認証双方の結果は { id, rawId, response: {...}, type } の形で返し、そのままサーバにPOSTできる形を保ちます。

2-2. Android:Credential ManagerでPasskeys UIを起動

AndroidはCredential Managerを利用すると、Passkey/パスワード/サインインのUIが統一され、エコシステムの推奨に沿えます。結果のArrayBufferはBase64URLに変換してJSへ返します。

// android/app/src/main/java/.../PasskeyModule.kt(概略)
class PasskeyModule(private val activity: Activity) : ReactContextBaseJavaModule() {
  override fun getName() = "PasskeyModule"

  @ReactMethod
  fun create(options: ReadableMap, promise: Promise) {
    // options(JSON) → CreatePublicKeyCredentialRequest に変換
    // CredentialManager.getCredential() を呼び出し、OS生体UIで登録
    // attestationObject / clientDataJSON 等を Base64URL にして WritableMap で返す
  }

  @ReactMethod
  fun get(options: ReadableMap, promise: Promise) {
    // options(JSON) → GetPublicKeyCredentialRequest に変換
    // 成功時: authenticatorData / clientDataJSON / signature / userHandle を返す
  }
}

意図:Android 14+ の挙動やStrongBox有無、デバイス認証(PIN/パターン)でのフォールバックなどは端末差が出ます。機能検出(Feature Flags)で安全側に倒し、失敗時の案内を統一します。
重要:チャレンジは毎回ランダムrpId(ドメインに相当)はサーバ生成の値と一致させ、検証はサーバで厳密に行います。

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

iOSはASAuthorizationPlatformPublicKeyCredentialProviderでcreate/getを発行し、ASAuthorizationControllerでUIを起動します。iCloud Keychainによる同期型パスキーにも対応します。

// ios/PasskeyModule.swift(概略)
@objc(PasskeyModule)
class PasskeyModule: NSObject {
  @objc func create(_ options: [String: Any], resolver: RCTPromiseResolveBlock, rejecter: RCTPromiseRejectBlock) {
    // options(JSON) → ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest
    // controller.performRequests() → 成功時に attestationObject / clientDataJSON を返却
  }

  @objc func get(_ options: [String: Any], resolver: RCTPromiseResolveBlock, rejecter: RCTPromiseRejectBlock) {
    // options(JSON) → ASAuthorizationPlatformPublicKeyCredentialAssertionRequest
    // 結果: authenticatorData / clientDataJSON / signature / userID
  }
}

意図:iOSはUI/生体体験が整っている反面、UIキャンセル理由(ユーザー中断/ロック/生体失敗)を丁寧に取り分けて、RN側で適切な再試行や別手段に誘導することが信頼感に直結します。
どちらのOSでも、JSへ返す前にBase64URLに正規化して、サーバがそのまま検証できる形に整えます。

3. サーバ側のWebAuthn検証とセッション設計

3-1. 登録・認証オプションの発行(Node例)

サーバは「create/get」のOptionsを発行し、結果のAttestation/Assertionを検証します。ここではNode+代表的なWebAuthnユーティリティを使う例で考え方を示します。

// サーバ(概略):create/get の Options を発行
app.get('/webauthn/create-options', (req, res) => {
  // rp.id はサービスのドメイン。challenge は毎回ランダム
  res.json({
    rp: { name: "Bank App", id: "example.com" },
    user: { /* ... */ },
    challenge: "base64url",
    pubKeyCredParams: [{ alg: -7, type: "public-key" }],
    timeout: 60000
  });
});

app.get('/webauthn/get-options', (req, res) => {
  res.json({
    challenge: "base64url",
    timeout: 60000,
    rpId: "example.com",
    allowCredentials: [ /* ... */ ]
  });
});

意図:rp.id(ドメイン)とchallenge(毎回ランダム)を正しく設定するのが要。ユーザーに紐付いたallowCredentialsで想定デバイスからの認証に限定します。
本番ではライブラリの検証関数(署名/証明書/カウンタ)を用い、リプレイとカウンタ降下を拒否します。

3-2. 署名検証(イメージ)とトークン払い出し

検証成功後は短寿命アクセストークンを払い出し、リフレッシュは「静的長寿命」よりも再認証寄りで設計するのが金融向きです。

// 検証成功後のセッション発行(概略)
if (verifyAssertionOk) {
  const access = issueAccessToken({ sub: userId }, { expiresIn: "10m" });
  // オプション:DPoP/MTLS/端末バインドでトークンの持ち回りを抑制
  res.json({ access });
}

意図:窃取対策として、有効期限は短く・権限は最小に。高リスク操作は都度生体(step-up)で追加確認します。
さらに、APIにはnonce/リクエストIDを付けて冪等性・リプレイ対策を行い、監査ログにはtraceId・端末情報・結果を残します(PIIは含めない)。

3-3. エラー設計(ユーザー案内の質が信頼を作る)

代表的なエラーは「生体失敗」「キャンセル」「期限切れ」「別端末」。ユーザー向け文言を一貫させ、次に何をすれば良いかを明示します。

例:「生体認証に3回失敗しました。パスコードで続行する/別の端末で承認する」案内の質がサポート負荷と離脱率に直結します。
失敗イベントは監査に送信し、失敗率やキャンセル率をダッシュボード化。UI/アルゴリズムの改善へフィードバックします。

4. フォールバック・復旧と運用

4-1. フォールバック:端末PIN/パスコード・OTP・窓口

金融ではゼロダウンが重要です。生体が使えない状況に備え、OSのDevice Credential(端末PIN/パスコード)や、限定的なOTP(一時的・低権限)を用意します。

ただしOTPはフィッシング耐性が低いため、低リスク操作のみ許可し、送金などはブロック。どうしても必要な場合に限定し、早期にパスキーへ復帰させます。
最終手段として、窓口/KYC再確認による再登録フローを明文化します。

4-2. 機種変更・紛失:自己失効と再登録の導線

ユーザーが自分で端末を失効できる画面を提供し、悪用可能時間を最小化します。失効時は全セッション無効化・通知送付を自動化。

機種変更は、同期型パスキーがあればスムーズ。ただし新端末は一定期間、高リスク操作に制限を掛け、利用履歴が安定するまで段階解除します。
これらの運用ルールは利用規約とUIに明記し、トラブル時の期待値ギャップをなくします。

4-3. セキュアストレージ最小化と可観測性

RN側で保存する情報は最小限(表示名・設定など)。トークンはメモリ優先、必要時のみ暗号化ストレージ(Keychain/Keystore)+生体ゲートで保護。

可観測性は、イベントスキーマを先に決め、成功/失敗/キャンセル/所要時間を計測。端末・OS・アプリ版別にカットして劣化を早期発見します。
監査ログは改ざん困難なストレージへエクスポートし、保持/削除ポリシーをガバナンスとして定義します。

5. 画面統合とサンプル呼び出し(RN側)

5-1. 登録(create)の呼び出し例と意図

RNの画面から、サーバで生成したcreationOptionsを受け取り、ネイティブを叩いて結果をサーバへ返します。成功後はユーザーに登録名登録日時をフィードバックし、監査イベントを送出します。

// 登録フロー(抜粋)
const registerWithPasskey = async () => {
  const options = await api.get('/webauthn/create-options');
  const attestation = await Passkeys.create(options.data);
  await api.post('/webauthn/verify-attestation', attestation); // サーバで検証
  Alert.alert('登録完了', 'この端末のパスキーを登録しました');
};

意図:クライアントは中継+UXに徹します。エラー時は「次にできること」(別手段/再試行)を明示。
監査イベント例:passkey.create.success / failure(traceId付与、PII除外)。

5-2. 認証(get)の呼び出し例と意図

認証時は、APIからrequestOptionsを取得→ネイティブ呼び出し→検証成功でアクセストークンを受領、という流れ。トークンは短寿命で払い出し、重要操作はstep-upで再認証します。

// 認証フロー(抜粋)
const signInWithPasskey = async () => {
  const options = await api.get('/webauthn/get-options');
  const assertion = await Passkeys.get(options.data);
  const { access } = await api.post('/webauthn/verify-assertion', assertion).then(r => r.data);
  session.setAccessToken(access); // メモリ or 暗号化ストレージ
};

意図:検証はサーバ、RNは「正しいリクエストを作り、正しい結果を返す」。トークン保存は最小化し、アプリ終了で破棄される設計を基本に。
失敗時の分岐は「生体失敗」「キャンセル」「期限切れ」で文言を変え、サポート問い合わせを減らします。

5-3. 高リスク操作のstep-up(都度生体)の導線

送金確定など高リスク操作では、step-upとして再度Passkeys.get()を呼び、安全性を担保します。時間制限(5分など)を設け、期限切れは再要求します。

// Step-up 認証(概念例)
const confirmTransfer = async () => {
  // 事前に /webauthn/get-options?purpose=transfer 等で限定オプションを取得
  const assertion = await Passkeys.get(optionsForTransfer);
  await api.post('/transfer/confirm', { assertion, txId });
  Alert.alert('送金完了', '取引ID: ' + txId);
};

意図:「高リスク操作=都度確証」をユーザーに明確化。これにより不正送金耐性と説明可能性(監査)が向上します。
監査にはpurposetxIdを付け、誰が何を承認したかが追えるようにします。

まとめ

React NativeでのPasskeys実装は、ネイティブ連携サーバ検証の二段構えが鍵です。RNは状態と通信、ネイティブは認証UIと安全領域、サーバは厳格検証と短寿命セッション。フォールバック・復旧・監査・可観測性まで含めて設計すれば、金融レベルの「安全 × 使いやすさ」を両立できます。

参考URL

採用情報 長谷川 横バージョン
SHARE
PHP Code Snippets Powered By : XYZScripts.com
お問い合わせ