課金状態はクライアントに書かせない — Webhook をサーバ側の真実にする
家族向けアプリ「ぽんっ!」(旧称:でいりんぐ)はプレミアム(広告なし+機能拡張)を課金で提供しています。当初は、購入結果をクライアントが判定して課金状態を表すフィールドをデータベースに直接書き込む実装でした。これはセキュリティ上まずいので、課金状態の更新をサーバ側に一本化しました。なぜそう判断したのか、設計の考え方を残します。
背景(なぜ着手したか)
この変更は、初回リリース前に攻撃者視点で行ったセキュリティ点検で「最優先で直すべき」と判断した項目でした。課金状態を決めるフィールドをクライアントが書けると、アプリを改造すれば誰でもプレミアムになれてしまう——よくある指摘ですが、実際に自分のアプリで成立すると分かると放置できません。真実(source of truth)をサーバ側へ移す作業に着手しました。
なぜクライアント書き込みが危険か
課金状態を決めるフィールドをクライアントが書けるということは、改造クライアントが自分を勝手にプレミアムにできるということです。クライアントは基本的に信頼できない実行環境なので、「支払われたか」の最終判定をそこに置くのは筋が悪い。ここは真実をサーバに置くべき典型例でした。
方針: 更新経路は Webhook だけにする
課金プラットフォーム(RevenueCat)の Webhook を受けるサーバ関数(Cloud Functions)だけが課金フィールドを更新する構成にしました。
- セキュリティルールで課金状態フィールドをロックする。クライアントからの書き込みは拒否。
- 更新は 課金プラットフォームの Webhook → サーバ関数の経路のみ。購入・更新・失効といったイベントを受けて、サーバがフィールドを書く。
- 購入プラットフォーム側のユーザー識別子を、自前のユーザー ID に揃えて突き合わせる(購入時にログインさせておく)。ここがずれると「別人の購入が反映される」事故につながるため、最初に固定する。
- 権利(エンタイトルメント)の識別子は、クライアントとサーバで完全一致していないと状態がずれるので、定数として一元管理して揃える。
判定の主体をサーバに寄せることで、「購入したのに反映されない」も「改造で勝手にプレミアム」も塞げます。
Webhook 認証は定数時間で比較する
Webhook は公開エンドポイントなので、呼び出し元が本物かを検証する必要があります。共有シークレットをヘッダで受け取り、サーバ側の期待値と突き合わせます。このとき、単純な文字列一致ではなく、ハッシュ化したうえで定数時間比較にしました。考え方はこうです(言語標準の暗号ユーティリティを使った一般的なパターン)。
// 受信値と期待値を、まず同じ長さのハッシュに通してから定数時間で比較する
function isAuthorized(received, expected) {
const a = sha256(received);
const b = sha256(expected);
return constantTimeEqual(a, b); // 長さ由来・一致位置由来のリークを避ける
}
通常の文字列一致は一致するまでの比較時間が入力によって変わり得るため、理屈のうえではタイミング攻撃の材料になります。定数時間比較を使い、先にハッシュへ通して長さを揃えることで、比較時間から情報が漏れないようにしています。共有シークレットそのものはコードにもログにも残さず、シークレットマネージャで管理します(記事にも当然載せません)。
まとめ
- 課金のような改ざんされて困る状態は、真実をサーバに置く。クライアントには書かせず、ルールでロックする。
- 更新経路は Webhook → サーバ関数に一本化し、購入プラットフォームのユーザー識別子を自前の ID に揃えて突き合わせる。
- Webhook の認証は ハッシュ化+定数時間比較。シークレットは環境変数やシークレットマネージャに置き、記事やログには絶対に残さない。