12k
All articles

シングルページアプリで 3D セキュアを扱う方法

Stripeを使ったSPAで3D Secureを処理する方法を解説。決済状態を保存し、復帰ルートを再構築して、PaymentIntentの状態をサーバー側で確認します。

OpenReplay Team
OpenReplay Team
シングルページアプリで 3D セキュアを扱う方法

シングルページアプリ(SPA)で 3D セキュアを扱うには、次の手順を踏みます。まず銀行の認証ページへ引き渡す前に、カートと PaymentIntent ID を保存します。次に顧客を専用のリターンルートに戻し、そのルートでサーバーからチェックアウトの状態を再構築します。そして、注文処理(フルフィルメント)を行う前に、サーバー側で決済結果を確認します。

このバグは通常、次のような形で現れます。顧客が「支払う」をタップして銀行のページに移動し、戻ってくると、カートが空になっていたり、スピナーが回り続けたりします。その結果、二重に支払ってしまう顧客も出てきます。本記事では、Stripe の Payment Intents API における戻り先 URL のルール、iframe を使う方法、サーバー側での確認、そして各パスを検証するためのテストカードについて解説します。

重要なポイント

  • 銀行へのフルページリダイレクトによって SPA はアンロードされます。そのため、メモリ上にしか保持していないカートやチェックアウトの状態は、顧客が戻ってきた時点で失われています。
  • Stripe は return_url に payment_intent と payment_intent_client_secret を付与します。これらはどの PaymentIntent かを示すものであり、決済が成功したかどうかを示すものではありません。
  • リターンルートでは URLSearchParams で ID を読み取り、サーバーにステータスを問い合わせます。すでに成功している PaymentIntent に対して支払いボタンを表示してはいけません。
  • 3D セキュア用の iframe には sandbox 属性を付けてはいけません。また、CSP で https://js.stripe.com、https://hooks.stripe.com、および return_url のオリジンからのフレームを許可する必要があります。
  • 注文処理は、サーバー側で PaymentIntent を取得(retrieve)して確認した後、または payment_intent.succeeded の Webhook を受信した後にのみ行ってください。

3D セキュアとは

3D セキュア(3DS)は、オンライン決済の際にカード発行会社が実施する確認手続きで、支払いを行っているのがカード所有者本人であることを確認するためのものです。バックグラウンドで完了する場合もあれば、顧客の操作が必要な場合もあります。たとえば、スマートフォンに送信されたワンタイムパスコードを入力する、といった操作です。Stripe の強力な顧客認証(SCA)ガイドによると、SCA は顧客が開始する決済を対象とした英国および欧州の規制です。また、Stripe の SCA 対応ページでは、カード決済がこの規制に準拠する手段として 3D セキュアが挙げられています。実務上は、事業者とカード発行会社の双方が EEA(欧州経済領域)または英国にある場合、ほとんどのオンラインカード決済で 3DS が必須となります。

例外はウォレットです。Stripe の 3DS 認証ガイドでは、3DS に対応していない取引の例として、ウォレットとオフセッション決済が挙げられています。Payment Request API のようなブラウザネイティブのウォレットフローで、通常チャレンジのステップが省略されるのはこのためです。

なぜ SPA は 3D セキュアの途中で状態を失うのか

SPA が 3D セキュアの途中で状態を失うのは、チェックアウトの過程でページから離れるためです。顧客が銀行のサイトに移動するか、確認処理の完了時に Stripe が顧客を return_url に送ります。顧客が戻ってきたとき、アプリは中断した地点から処理を再開しなければなりません。しかし、ルーターの状態、ストア、コンポーネントツリーはすべて失われています。戻ってくるのは、return_url でのコールドブートです。

決済が成功した場合もページから離れます。stripe.confirmPayment はデフォルトで、確認処理が完了するとすぐに顧客を return_url に送ります。そのため、そのページ上で Promise が解決されることはなく、await 以降のコードは実行されません。

状態を永続化し、専用の return_url を設定する

Stripe の 3D セキュアのリダイレクトが完了すると、顧客は payment_intent と payment_intent_client_secret という 2 つのクエリパラメータが付いた状態で return_url に到達します。これらは PaymentIntent を識別するものですが、決済が成功したかどうかについては何も示しません。return_url は、Stripe が顧客を送り返すページです。この目的専用のルートを指定してください。

Payment Element を使う場合、stripe.confirmPayment は 3DS をダイアログで表示するか、顧客を銀行のページに送ります。その後、デフォルトでは確認処理の完了時に return_url へフルページリダイレクトを行います。カード決済でこのリダイレクトを省略するには、redirect: 'if_required' を指定します。ただし、リダイレクト型の決済手段では引き続きページから離れるため、成功時の結果をコード側で処理する必要があります。

import type { Stripe, StripeElements } from '@stripe/stripe-js';

const PENDING_KEY = 'checkout:pending';

export async function pay(
  stripe: Stripe,
  elements: StripeElements,
  cartId: string,
  paymentIntentId: string,
): Promise<void> {
  sessionStorage.setItem(PENDING_KEY, JSON.stringify({ cartId, paymentIntentId }));

  const { error } = await stripe.confirmPayment({
    elements,
    confirmParams: { return_url: `${window.location.origin}/checkout/return` },
  });

  if (error) {
    showError(error.message ?? 'Payment failed');
    enableForm();
  }
}

コードが await より先に進むのは、確認処理が即座に失敗した場合だけです。この場合、顧客はまだ同じページにいるので、エラーを表示してフォームを再度有効にします。sessionStorage のデータは、同じタブ内であればページ遷移やリロードを経ても保持されます。sessionStorage はカードへのポインタとして使い、カート本体はサーバー側に保持してください。

リターンルートをサーバーから構築する

リターンルートでは、クエリ文字列から PaymentIntent ID を読み取り、アドレスバーからクライアントシークレットを削除し、バックエンドに結果を問い合わせます。描画する内容をメモリから取得することは一切ありません。クエリは URLSearchParams で解析してください。最初の = 以降の部分文字列を取り出す方法は、URL に 2 つ目のパラメータが含まれた時点で破綻するため避けましょう。

type PiStatus =
  | 'succeeded' | 'processing' | 'requires_capture'
  | 'requires_payment_method' | 'requires_action'
  | 'requires_confirmation' | 'canceled';

export async function loadReturnState(): Promise<PiStatus | null> {
  const params = new URLSearchParams(window.location.search);
  const pending = JSON.parse(sessionStorage.getItem(PENDING_KEY) ?? 'null') as
    | { paymentIntentId: string }
    | null;
  const id = params.get('payment_intent') ?? pending?.paymentIntentId;
  if (!id) return null;

  history.replaceState(null, '', window.location.pathname);

  const res = await fetch(`/api/payments/${encodeURIComponent(id)}`);
  if (!res.ok) throw new Error(`Status lookup failed: ${res.status}`);
  const { status } = (await res.json()) as { status: PiStatus };
  return status;
}

リロード後などでクエリ文字列が失われている場合、このルートは sessionStorage に保存したポインタにフォールバックします。history.replaceState は、アドレスバーと現在の履歴エントリからシークレットを削除します。アナリティクスやセッションリプレイのスクリプトが URL を読み取る前に、できるだけ早い段階で実行し、シークレットが記録されないようにしてください。各ステータスは、次のように画面に対応付けます。

ステータスリターン画面
succeeded注文確認画面。保留中のキーを削除する
requires_capture確認画面(オーソリとキャプチャを分けて行っている場合)
processing「決済を確認しています」と表示し、再度サーバーにポーリングする
requires_payment_method決済失敗。カートを再構築し、別のカードの入力を求める
requires_action顧客がまだ認証中か、離脱した可能性がある。再開を促す
canceled決済キャンセル。新しいチェックアウトを開始する

再度支払いを促す前に、既存の PaymentIntent のステータスを確認してください。メモリから状態を再構築するリターンルートでは、すでに成功した PaymentIntent に対して新しい「支払う」ボタンが表示されてしまうことがあります。この不具合はページのアンロードをまたいで発生するため、エラーログからチェックアウト、銀行ページへの遷移、リターンの 3 つを結び付けることはほとんどできません。リターン時の訪問をセッションリプレイで確認すれば、顧客が実際に何を見たのかがわかります。空のカート、終わらないスピナー、あるいは 2 つ目の支払いボタンです。

3D セキュアにはリダイレクトと iframe のどちらを使うべきか

フルページリダイレクトはデフォルトの方式で、最もシンプルな選択肢です。iframe 方式では SPA を読み込んだまま維持できますが、自前で実装する部分が増え、カード決済でしか使えません。iframe 方式では、アクションの自動処理を無効にして確認処理を行い、next_action(顧客が完了する必要があると Stripe が示すステップ)を読み取り、next_action.redirect_to_url.url をフレーム内に読み込みます。

リダイレクトiframe
認証が行われる場所銀行のページ(トップレベル)モーダル内の銀行のページ
SPA のアンロードありなし
実装が必要なものリターンルートフレーム、postMessage 用ページ、リスナー
CSP への影響フレームに関してはなしframe-src の設定
フォールバック不要リダイレクト

以下のスニペットでは、Payment Element のカード情報から PaymentMethod を作成し、Stripe 自身による 3DS 処理を無効にした状態で確認処理を行います。これを機能させるには、paymentMethodCreation: 'manual' を指定して Elements インスタンスを作成します。Stripe の Elements リファレンスによると、このオプションを指定することで、stripe.createPaymentMethod が Payment Element から PaymentMethod を作成できるようになります。また、事前に elements.submit() を呼び出してフォームを検証する必要があります。

const elements = stripe.elements({ clientSecret, paymentMethodCreation: 'manual' });
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');

// When the customer clicks "Pay":
const { error: submitError } = await elements.submit();
if (submitError) {
  showError(submitError.message ?? 'Check your card details');
  return;
}

const { paymentMethod, error: pmError } = await stripe.createPaymentMethod({ elements });
if (pmError) {
  showError(pmError.message ?? 'Payment failed');
  return;
}

const { paymentIntent, error } = await stripe.confirmCardPayment(
  clientSecret,
  { payment_method: paymentMethod.id, return_url: `${location.origin}/checkout/3ds-done` },
  { handleActions: false },
);
if (error) {
  showError(error.message ?? 'Payment failed');
  enableForm();
  return;
}

const action = paymentIntent?.next_action;
if (paymentIntent?.status === 'requires_action' && action?.redirect_to_url?.url) {
  const frame = document.createElement('iframe');
  frame.src = action.redirect_to_url.url;
  frame.width = '600';
  frame.height = '400';
  container.appendChild(frame);
}

/checkout/3ds-done ページでは window.top?.postMessage('3ds-complete', window.location.origin) を実行します。親ページは、MDN の postMessage に関するガイダンスが推奨するとおり、処理を行う前に event.origin が自身のオリジンと一致するかを確認します。その後、フレームを削除し、サーバーにステータスを問い合わせます。

3D セキュア用の iframe に sandbox 属性を付けてはいけません。フレーム内に読み込まれる内容の一部はカード発行会社が制御しています。Stripe の 3DS ガイドでも、一部の発行会社のページはサンドボックス化されると動作しなくなり、決済が失敗すると指摘されています。Content Security Policy を送信している場合は、その frame-src ディレクティブで https://js.stripe.com、https://hooks.stripe.com、および return_url のオリジンを許可する必要があります。

また、顧客のために代替手段も用意しておきましょう。window.location.assign(url) を呼び出す「銀行のページを開く」リンクです。この場合、銀行は顧客を /checkout/3ds-done にフルページで送ります。そのため、このページでは window.top === window かどうかを確認し、該当する場合は同じクエリ文字列を付けて /checkout/return に転送するようにします。こうすることで、顧客はリダイレクト方式と同じリターンルートを通ることになります。

なぜ決済結果をサーバー側で確認しなければならないのか

3D セキュアの後に return_url に到達したという事実は、決済結果について何も示しません。その URL には誰でもクエリ文字列を入力できるからです。注文処理は、サーバー側での取得(retrieve)か Webhook の結果のみに基づいて行うべきです。

import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

app.get('/api/payments/:id', async (req, res) => {
  const intent = await stripe.paymentIntents.retrieve(req.params.id);
  res.json({ status: intent.status });
});

レスポンスを返す前に、その PaymentIntent が現在のセッションのカートに属していることを確認してください。Stripe の決済ステータスの更新に関するガイドでは、注文処理はポーリングではなく Webhook を起点に行うよう推奨されています。注文処理には payment_intent.succeeded を、顧客への通知には payment_intent.payment_failed をリッスンします。エンドポイント側の実装については、OpenReplay の Webhook ガイドで解説しています。3DS の確認が成功すると、対象となる不正利用の不審請求(チャージバック)にかかるコストは通常カード発行会社に移転しますが、Stripe はこれが保証されるものではないと明言しています。

3D セキュアのフローをテストする方法

Stripe のテストカードを使うと、各分岐をトリガーできます。CVC と郵便番号には任意の値を、有効期限には任意の将来の日付を使用してください。

カード動作
4000000000003220常に 3DS2 が必要
40000084000016293DS が必要で、その後 card_declined で拒否される
40000000000030553DS に対応しているが必須ではない
42424242424242423DS に対応しているが、カードが登録されていないため認証画面は表示されない

テストキーを使用している場合、Stripe は認証の成功・失敗を選択するボタンが付いた模擬の銀行ページを表示します。テストは必ず自社のフロントエンドから行ってください。Stripe ダッシュボードで作成した決済では、3DS のリダイレクトが省略されます。各カードについて、リターンルートが読み込まれたら一度リロードしてください。リロード後も正しい画面が表示されるはずです。

まとめ

SPA において、3D セキュアへの引き渡しはページ遷移であり、リターンルートはコールドスタートです。確認処理の前にカートへのポインタを永続化し、顧客が戻ってきたらサーバーから状態を再構築してください。そして、リダイレクトは決済完了の証拠ではなく、ステータスを確認しに行くための合図として扱ってください。次のステップとして、/checkout/return ルートを追加し、4 種類のテストカードすべてで動作を確認しましょう。成功済みの PaymentIntent に対して支払いボタンが表示されるケースがないことを確認してください。

よくある質問

3D セキュア 2 におけるフリクションレスフローとチャレンジフローの違いは何ですか?

フリクションレスフローでは、発行会社がバックグラウンドで決済を認証するため、顧客に追加のステップは表示されません。チャレンジフローでは、ワンタイムパスコードの入力など、顧客による操作が必要です。Stripe の SCA ガイドによると、フリクションレスの 3DS 確認が成功した場合でも、不正利用の責任は発行会社に移転します。一方、代わりに SCA の適用除外が適用された場合、不正利用による不審請求の責任は事業者側に残ります。

Stripe の決済で 3D セキュアを強制するにはどうすればよいですか?

PaymentIntent の作成時または確認時に、payment_method_options[card][request_three_d_secure] を 'any' または 'challenge' に設定します。通常、Stripe は Radar を使用し、リスクに基づいて 3DS を要求するタイミングを判断します。このパラメータを設定すると、Stripe はその決済で 3DS を試行し、Radar の動的 3DS ルールはその決済には適用されなくなります。'any' はフリクションレスフロー寄り、'challenge' はチャレンジフロー寄りの設定ですが、最終的な判断は発行会社が行います。Stripe によると、手動でのトリガーは独自の不正検知エンジンを運用しているチーム向けの機能です。

顧客が 3D セキュアの途中でチェックアウトを離脱した場合、どうすべきですか?

顧客が戻ってきたら、新しい PaymentIntent を作成せず、既存のものを再利用してください。Stripe は、中断したチェックアウトを再開する際には同じ PaymentIntent を使い続け、カートの内容が変わった場合は金額を更新するよう推奨しています。認証が途中で放棄された場合、PaymentIntent は通常 requires_action の状態のままになります。まずサーバー側でステータスを確認し、すでに succeeded または processing になっている場合は、決済フォームではなく注文の状態を表示してください。

Stripe がクライアントシークレットを戻り先 URL に含めるのは安全ですか?

クライアントシークレットは機密情報として扱ってください。Stripe の API リファレンスでは、このシークレットがあればブラウザから決済を完了できるため、顧客本人以外が目にすることがあってはならず、保存やログ出力も絶対に行わないよう警告しています。payment_intent パラメータを読み取ったら、history.replaceState でクエリ文字列を削除してください。アナリティクスやセッションリプレイのスクリプトが URL を読み取る前に、できるだけ早い段階で実行し、シークレットが記録されないようにします。チェックアウトページは TLS 経由で配信してください。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.