Stripe Pricing Tableのclient-reference-id改ざんを防ぐため、HMAC署名で信頼できないデータをトークン化し検証するセキュリティフローの図です。人物が説明しています。

Stripe Pricing Table はノーコードで料金表を埋め込める便利な Web Component ですが、client-reference-id をフロントエンドから自由に渡せるため、そのままではなりすましのリスクがあります。本記事では HMAC 署名トークンでこの改ざんを防ぐ実装方法を、サーバー・フロント・テストの 3 つの観点で解説します。

こんな人におすすめ

  • Stripe Checkout Session から Pricing Table への移行を検討している方
  • SaaS の決済に Stripe を採用していて、ユーザー紐付けのセキュリティが気になる方
  • Cloudflare Workers などのエッジ環境で Stripe Webhook を受ける構成の方
  • フロントに秘匿情報を置かずに決済導線を組みたい方

Stripe Pricing Table とは

Stripe Pricing Table は、ダッシュボードで作成した料金表を <stripe-pricing-table> という Web Component として埋め込む機能です。Checkout Session を自前で生成していた頃と比べ、料金プランの変更がダッシュボード側で完結し、フロントの再デプロイが不要になります。プラン追加や価格改定の運用コストが大きく下がるのが移行の最大のメリットです。

埋め込みは公式スクリプトを読み込んだうえで、属性を渡すだけです。

<stripe-pricing-table
  pricing-table-id="prctbl_xxx"
  publishable-key="pk_live_xxx"
  client-reference-id="user_123"
></stripe-pricing-table>

client-reference-id の改ざんリスク

問題は client-reference-id です。これは決済完了後の Webhook で「どのユーザーの支払いか」を識別するためにそのまま返ってくる値ですが、HTML 属性であるため、ブラウザの開発者ツールから誰でも書き換えられます。

もし client-reference-id に生の userId を入れていると、攻撃者が他人の userId を指定して決済し、別アカウントを有料化させる(あるいは課金状態を混乱させる)といった操作が可能になります。フロントから渡る値は信用できない、という大原則がここでも当てはまります。

HMAC 署名トークンの設計

そこで、userId をそのまま渡すのではなく、サーバー側の秘密鍵で署名したトークンを渡します。フォーマットはシンプルに次の 3 部構成にします。

userId.expUnixSec.hmacHex

userId(誰の決済か)と expUnixSec(有効期限の UNIX 秒)を結合した文字列に HMAC 署名を付けるだけです。秘密鍵はサーバーだけが持つため、攻撃者は任意の userId に対する正しい署名を作れません。有効期限を入れることで、トークンの長期使い回しも防げます。

サーバー側: トークンの発行

トークン発行は userId と有効期限を結合し、HMAC-SHA256 で署名するだけです。

// トークン発行
async function createCheckoutReference(
  secret: string,
  userId: string,
  expUnixSec: number,
): Promise<string> {
  const message = `${userId}.${expUnixSec}`
  const signature = await hmacHex(secret, message)
  return `${message}.${signature}`
}

hmacHex は Web Crypto API(crypto.subtle)でも Node の crypto でも実装できます。Cloudflare Workers なら Web Crypto API がそのまま使えます。

Webhook 側: トークンの検証

Webhook で受け取った client_reference_id を検証します。ここで重要なのが、署名比較に timingSafeEqual 相当のタイミング安全な比較を使うことです。通常の文字列比較は一致しない位置で早期に処理を打ち切るため、比較時間から署名を推測されるタイミング攻撃の余地が生まれます。

// トークン検証
async function verifyCheckoutReference(secret: string, reference: string): Promise<string | null> {
  const parts = reference.split('.')
  if (parts.length !== 3) return null
  const [userId, expRaw, signature] = parts
  const exp = Number(expRaw)
  if (!Number.isFinite(exp) || Date.now() / 1000 > exp) return null
  const expectedSignature = await hmacHex(secret, `${userId}.${expRaw}`)
  if (!timingSafeEqualHex(signature, expectedSignature)) return null
  return userId
}

フォーマット不正・期限切れ・署名不一致のいずれかであれば null を返し、正当な場合のみ userId を返します。Webhook 側はこの戻り値だけを信頼し、null の場合は課金処理をスキップします。

フロント: 自己完結する React コンポーネント

埋め込み側は、設定とトークンの取得・スクリプトのロード・レンダリングまでを 1 つのコンポーネントで完結させます。publishable-keypricing-table-id はビルドに埋め込まず、/api/config 経由で取得する点がポイントです。

// React埋め込みコンポーネント
export function StripePricingTableCard() {
  const [state, setState] = useState<PricingTableState | null>(null)

  useEffect(() => {
    let cancelled = false
    void (async () => {
      try {
        const [config, ref] = await Promise.all([
          loadStripePricingTableConfig(),
          fetchCheckoutReference(),
        ])
        if (cancelled || !config || !ref.reference) return
        await loadStripePricingTableScript()
        if (cancelled) return
        setState({ config, reference: ref.reference, email: ref.email })
      } catch {
        if (!cancelled) setState(null)
      }
    })()
    return () => { cancelled = true }
  }, [])

  if (!state) return null
  return (
    <stripe-pricing-table
      pricing-table-id={state.config.pricingTableId}
      publishable-key={state.config.publishableKey}
      client-reference-id={state.reference}
      customer-email={state.email ?? undefined}
    />
  )
}

環境別設定の管理

publishable-key は公開鍵とはいえ、本番・ステージングで値が異なります。ビルド時に固定するとデプロイ単位が環境に縛られるため、これらを secret として管理し、/api/config から配信する構成にしています。これにより、同じビルド成果物を複数環境で使い回せます。pricing-table-id も同様に config 経由で渡します。

設定不備時は「非表示」にフォールバック

上のコンポーネントで注目したいのは catch と最後の if (!state) return null です。config が取れない、トークンが取れない、スクリプトのロードに失敗したといったケースでは、エラーメッセージを出すのではなく、料金表そのものを非表示にしています。

決済導線で中途半端に壊れた UI を出すより、出さない方が安全だという判断です。設定不備のまま「購入できないボタン」を見せると、ユーザーに不信感を与えるだけでなく、サポート問い合わせも増えます。表示できないなら静かに引っ込める、というフォールバックを基本方針にしています。

E2E テストでのスタブ

E2E テストでは実際の Stripe を叩かず、/api/config/api/billing/checkout-referencepage.route でインターセプトしてスタブ化します。config は本物のレスポンスをベースに必要なフィールドだけ上書きすると、テストが壊れにくくなります。

// E2Eスタブ
export async function stubStripePricingTableApi(
  page: Page,
  options: { pricingTableId?: string; publishableKey?: string; reference?: string; email?: string } = {},
): Promise<void> {
  await page.route('**/api/config', async (route) => {
    const response = await route.fetch()
    const body = (await response.json()) as Record<string, unknown>
    await route.fulfill({
      json: { ...body, stripePricingTableId: pricingTableId, stripePublishableKey: publishableKey },
    })
  })
  await page.route('**/api/billing/checkout-reference', async (route) => {
    await route.fulfill({ json: { reference, email } })
  })
}

config 側は route.fetch() で実レスポンスを取得してから一部だけ差し替え、reference 側は完全なスタブを返しています。これで Stripe のスクリプト読み込み以外の挙動をテストから制御できます。

まとめ

  • Stripe Pricing Table はフロントから client-reference-id を自由に渡せるため、生の userId をそのまま入れると改ざん・なりすましのリスクがある
  • userId.expUnixSec.hmacHex 形式の HMAC 署名トークンを使えば、サーバーの秘密鍵を持たない第三者は正当なトークンを作れない
  • 有効期限を含めることでトークンの使い回しを防ぐ
  • 署名比較は timingSafeEqual 相当のタイミング安全な比較を使う
  • publishable-key / pricing-table-id はビルドに埋め込まず /api/config 経由で配信し、環境間で同じ成果物を使い回す
  • 設定不備時はエラー表示ではなく「非表示」にフォールバックする
  • E2E テストは page.route で config・token エンドポイントをスタブ化し、Stripe 本体に依存しない