2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

サブスクリプション管理のセキュリティ

Last updated at Posted at 2025-12-19

サブスクリプション(継続課金)の管理は、ユーザーの「お金」と「権利」に直結するため、バックエンドエンジニアにとって最も慎重な実装が求められる分野です。

特に、自分のDBと決済プラットフォーム(Stripeなど)の間で 「状態の不一致」 が起きると、不正利用や過剰請求といった深刻な問題に繋がります。

1. セキュリティ更新失敗時のハンドリング

決済情報の更新(カード変更など)が失敗した際、最も重要なのは 「ユーザーの権限をどう安全に制限するか」 です。

① Webhookの活用と署名検証(最重要)

決済プラットフォームからの通知(Webhook)を信頼する前に、必ず署名(Signature)の検証を行ってください。これを行わないと、攻撃者が「支払いに成功した」という偽の通知を送り、タダでサービスを利用できてしまいます。

② 指数バックオフによるリトライ

一時的なネットワークエラーやDBのロックで更新に失敗した場合、単純にエラーを返して終わるのではなく、リトライ(再試行) の仕組みが必要です。
Goでは、time.After などを使って「1秒後、5秒後、30秒後...」と間隔を広げながら再試行する仕組みを作ります。

③ グレースピリオド(猶予期間)の設定

セキュリティ更新(支払失敗など)ですぐに権限を剥奪すると、ユーザー体験が悪化します。

  • 状態管理: active(有効), past_due(支払遅延), canceled(解約) のように細かくステータスを持ち、past_due の間は、機能は使えるが「カードを更新してください」と警告を出す設計にします

2. 退会時の自動キャンセル

ユーザーが「退会(アカウント削除)」したのに「課金が続いていた」というのは、法的なトラブルやブランド毀損を招く最悪のパターンです。

確実な連動フロー

アカウント削除のAPIを叩かれた際、以下の順序で処理を行います。

  1. 外部決済APIの呼び出し: Stripe等のAPIで「即時キャンセル」または「期間終了時にキャンセル」を実行
  2. 成功確認: 決済側から成功レスポンスが来てから、自社DBのステータスを更新
  3. 論理削除の検討: ユーザーデータを物理的に即削除するのではなく、「退会フラグ」を立てて一定期間保持することで、不整合が起きた際の調査を可能にします

※危険な実装例

// DBだけ消して、決済APIの失敗を無視している
func DeleteUser(id string) {
    db.DeleteUser(id) // 先にDBを消すと、後でAPIが失敗したときに誰の課金か追えなくなる
    paymentAPI.Cancel(id) 
}

☑セキュアな実装例(擬似コード)

func HandleWithdrawal(userID string) error {
    // 1. ユーザーのサブスクリプションIDを取得
    subID := db.GetSubscriptionID(userID)

    // 2. 先に外部決済APIをキャンセル
    err := stripe.CancelSubscription(subID)
    if err != nil {
        return fmt.Errorf("決済キャンセル失敗: %w", err)
    }

    // 3. 決済側の処理が成功してから、自社DBを更新(または削除)
    return db.MarkAsWithdrawn(userID)
}

3. 整合性を守るための「バッチ処理(照合)」

APIの通信エラーなどで、どうしても不整合(決済側は生きてるが、DBでは退会してる等)は起き得ます。これを防ぐための最後の砦が**「リコンシリエーション(照合)」**です。

手法 内容 目的
定期同期バッチ 1日1回、決済側の全リストと自社DBを突き合わせる 漏れているキャンセルを見つける
不整合アラート 決済通知が来たが、DBにユーザーがいない場合にSlack通知する 異常な状態を即座に検知する

初心者が意識すべき「防御的プログラミング」

  1. 外部を疑う: 決済APIが常に成功すると思わず、失敗時の「待避策」を必ず書く。
  2. 冪等性(べきとうせい)を保つ: 同じWebhookを2回受け取っても、2回課金したり、エラーで止まったりしないように実装する。
  3. ログを詳細に残す: 「いつ、どのAPIが、どんなエラーを返したか」のログがないと、お金のトラブルは解決できません。

Stripeなどのテスト環境(Test Mode)を使って、Webhookを受け取って署名を検証するコードを覗き

決済プラットフォーム(Stripeなど)から「支払いが完了した」「サブスクが解約された」といった通知を受け取る仕組みを Webhook と呼びます。

この通信はインターネットを通じて行われるため、「Stripeのふりをした攻撃者」 が偽の通知を送ってくるリスクがあります。これを防ぐのが 「署名検証(Signature Verification)」 です。

Goの標準的なHTTPサーバーとStripe公式ライブラリを使った、最も安全な実装例をステップバイステップで解説です。

1. 全体イメージ

  1. Stripe側: 送信するデータ(Payload)を「秘密の鍵(Webhook Secret)」を使って署名し、ヘッダーに付けて送ります
  2. Goアプリ側: 届いたデータと「自分の持っている秘密の鍵」を使い、署名が正しいか計算して照合します

2. 実装コード例(Go)

このコードは、Stripeからの通知を受け取り、正当性をチェックして、成功した場合のみ処理を進める「ガードマン」のような役割を果たします。

package main

import (
	"fmt"
	"io"
	"net/http"
	"os"

	"github.com/stripe/stripe-go/v76"
	"github.com/stripe/stripe-go/v76/webhook"
)

func handleWebhook(w http.ResponseWriter, r *http.Request) {
	// 1. ストライプから送られてきた「署名ヘッダー」を取得
	signature := r.Header.Get("Stripe-Signature")

	// 2. リクエストのボディ(中身)を読み込む
	payload, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "読み込み失敗", http.StatusServiceUnavailable)
		return
	}

	// 3. 署名の検証
	// 第2引数の endpointSecret は環境変数(.envなど)から取得します
	endpointSecret := os.Getenv("STRIPE_WEBHOOK_SECRET")
	event, err := webhook.ConstructEvent(payload, signature, endpointSecret)
	
	if err != nil {
		// 署名が合わない=偽物の可能性が高い
		fmt.Printf("⚠️ 署名検証に失敗しました: %v\n", err)
		w.WriteHeader(http.StatusBadRequest) // 400エラーを返す
		return
	}

	// 4. イベントの種類(タイプ)に合わせて処理を分岐
	switch event.Type {
	case "customer.subscription.deleted":
		// 退会時の自動キャンセル処理をここに書く
		fmt.Println("サブスクリプションが解約されました")
	case "invoice.payment_failed":
		// 支払い失敗時のハンドリングをここに書く
		fmt.Println("支払いに失敗しました")
	default:
		fmt.Printf("その他のイベント: %s\n", event.Type)
	}

	// 5. Stripeに対して「無事に受け取ったよ」と伝える(これがないと再送され続ける)
	w.WriteHeader(http.StatusOK)
}

func main() {
	http.HandleFunc("/webhook", handleWebhook)
	fmt.Println("Server started at :8080")
	http.ListenAndServe(":8080", nil)
}

3. 実装のポイントと注意点

① Webhook Secret(鍵)の管理

コード内の STRIPE_WEBHOOK_SECRET は、前回学んだ .env ファイル で管理しましょう。

# .envの中身
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxx...

※ この鍵を漏洩させると、誰でもあなたのアプリに偽の決済情報を送り込めてしまいます。

io.ReadAll(r.Body) の扱い

署名検証には「加工されていない生のデータ(Raw Body)」が必要です。JSONデコード(json.Unmarshal)などをする前に、必ず生データを取得して検証に回してください。

③ 冪等性(べきとうせい)の確保

Stripeは、あなたのサーバーが 200 OK を返さない限り、何度も同じ通知を送ってきます(最大3日間)。

  • 既に解約処理が終わっているのに、もう一度通知が来た場合でもエラーにならずに「処理済みとして 200 OK を返す」設計にするのがプロの書き方です。

4. テストする方法

本番のURLがなくても、Stripe公式の Stripe CLI を使うと、自分のパソコン(localhost)にWebhookを転送してテストできます。

  1. stripe listen --forward-to localhost:8080/webhook を実行
  2. 表示された whsec_... という鍵を .env に貼る
  3. 別のターミナルで stripe trigger customer.subscription.deleted を叩く

これで、本番さながらの「解約イベント」をGoで受け取る練習ができます。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?