署名検証とセキュリティ

Webhook 署名検証 HMAC X-RR-Signature セキュリティ

Webhookの受信URLは公開されているため、第三者が偽のリクエストを送ることができます。レシートローラーはすべての配信にWebhookのシークレットで計算した署名を付けているので、受信側は処理の前に必ず署名を検証してください。この記事では署名の計算方法、言語別の検証コード、シークレットの入れ替え方を説明します。

署名の仕組み

  • 署名はヘッダー X-RR-Signature に入っています
  • 値は sha256= に続けて、リクエストボディのバイト列そのもの(UTF-8)を、Webhookのシークレット(UTF-8)をキーにしてHMAC-SHA256で計算した結果を、小文字の16進文字列(64文字)にしたものです
  • 署名の対象はボディだけです。タイムスタンプのヘッダーはなく、ヘッダーの値は署名に含まれません
  • 同じイベントの再送では、ボディも署名も最初の送信と同じです
X-RR-Signature: sha256=5d41402abc4b2a76b9719d911017c592...(64文字)

配信に付くヘッダーは X-RR-Signature、X-RR-Event(イベント名)、X-RR-Delivery(イベントID)の3つです。

受信側の検証手順

  • JSONにパースする前の、受信したボディのバイト列を取り出します。パースしてから文字列に戻すと空白やキーの順序が変わり、署名が一致しません
  • 保管しているシークレットをキーにしてHMAC-SHA256を計算し、小文字の16進文字列にして先頭に sha256= を付けます
  • X-RR-Signature の値と、定数時間で比較する関数で照合します
  • 一致しなければ 401 などを返して処理しません(2xx 以外を返すと再送されますが、署名が変わらない限り結果は同じです)
  • 一致したら X-RR-Delivery で重複を確認してから処理します

サンプルコード

Node.js(Express)

import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.RR_WEBHOOK_SECRET;
const app = express();

function hasValidSignature(rawBody, header) {
  if (!header) return false;
  const expected = "sha256=" +
    crypto.createHmac("sha256", SECRET).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(header);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw でパース前のボディ(Buffer)を受け取る
app.post("/rr-webhook", express.raw({ type: "application/json" }), (req, res) => {
  if (!hasValidSignature(req.body, req.get("X-RR-Signature"))) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body.toString("utf8"));
  // X-RR-Delivery で重複を確認してから処理する
  res.status(200).end();
});

Python(Flask)

import hashlib, hmac, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["RR_WEBHOOK_SECRET"].encode("utf-8")

@app.post("/rr-webhook")
def rr_webhook():
    body = request.get_data()  # パース前のバイト列
    expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-RR-Signature", "")):
        abort(401)
    event = request.get_json()
    # X-RR-Delivery で重複を確認してから処理する
    return "", 200

C#(ASP.NET Core)

[HttpPost("/rr-webhook")]
public async Task<IActionResult> Receive()
{
    using var buffer = new MemoryStream();
    await Request.Body.CopyToAsync(buffer);
    var body = buffer.ToArray(); // パース前のバイト列

    var secret = Encoding.UTF8.GetBytes(_config["RR_WEBHOOK_SECRET"]!);
    var expected = "sha256=" + Convert.ToHexStringLower(HMACSHA256.HashData(secret, body));
    var received = Request.Headers["X-RR-Signature"].ToString();

    if (!CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(received)))
        return Unauthorized();

    // X-RR-Delivery で重複を確認してから処理する
    return Ok();
}

リプレイへの備え

署名はボディだけに対するものなので、一度届いた正しいリクエストを第三者がそのまま送り直すと、署名の検証は通ります。次の方法で防ぎます。

  • X-RR-Delivery(=ボディの id)を処理済みとして記録し、同じ値は処理しない。これは再送による重複を除くのと同じ仕組みです
  • ボディの timestamp は署名の対象に含まれるため、古すぎるリクエストを拒否する目安に使えます。ただし再送は最初の送信と同じ timestamp のまま約71分後まで届くので、許容幅は2時間程度より短くしないでください

シークレットの発行と入れ替え

  • シークレットはWebhookを登録したときに決まります。APIでは16〜64文字を指定するか省略して32文字の英数字を生成させ、開発者ポータルの画面では自動で生成されます
  • シークレットは登録時に一度だけ表示されます。あとから表示したり、作り直したりする機能はありません
  • シークレットを入れ替えるときは、新しいシークレットで同じURLのWebhookを登録し、受信側でしばらく新旧どちらのシークレットでも検証が通るようにしてから、古いWebhookを削除します。並行している間は同じイベントが両方のWebhookから届きますが、X-RR-Delivery は同じなので、重複の除去で1回だけ処理されます
  • 漏れた疑いがあるときは、すぐに新しいWebhookを登録して古いものを削除してください

以前に登録したWebhookの署名

2026年10月23日の修正より前に登録したWebhookは、シークレットではなくアプリのクライアントIDで署名されています。シークレットで検証して一致しない場合は、Webhookを登録し直してください。新しく登録したWebhookはシークレットで署名されます。

よくある実装ミス

ミス 起きること 対処
パースしたJSONを文字列に戻して計算する毎回一致しない受信したバイト列をそのまま使う
sha256= を付けずに比較する毎回一致しない計算結果の先頭に sha256= を付けるか、ヘッダーから取り除いて比較する
大文字の16進やBase64で比較する毎回一致しない小文字の16進にする
クライアントシークレットで計算する毎回一致しないWebhook登録時のシークレットを使う
== で比較するタイミング攻撃で署名を推測される可能性定数時間で比較する関数を使う
検証に失敗しても 2xx を返す偽のリクエストを受け入れたように見える401 を返す

シークレットの保管

  • コードやリポジトリに書かず、環境変数やシークレット管理サービスに置きます
  • 本番・ステージング・開発で別のWebhookを登録し、別のシークレットを使います
  • ログにシークレットや署名の値を出力しません

関連ガイド

公開日: 2026-04-27 更新日: 2026-10-26
タグ
API (26) OAuth (21) Android (13) iOS (12) Webhook (9) api (6) oauth (5) クイックスタート (5) トラブル (5) Node.js (4)