zukucode
主にWEB関連の情報を技術メモとして発信しています。

ASP.NET CoreでWebhookのHMAC署名を生成・検証する

Webhookの受信URLを知っているだけでは、届いたリクエストが正しい送信元から送られたものか判断できません。

この記事では、共有シークレットとHMAC-SHA256を使用してWebhookへ署名し、ASP.NET Coreの受信側で検証する方法を紹介します。

Webhook署名で確認できること

送信側と受信側だけが知っている共有シークレットを使い、Webhookの本文から署名を生成します。

受信側で同じ計算を行い、署名が一致すれば次のことを確認できます。

  • 共有シークレットを持つ送信元が署名した
  • 署名後に本文が変更されていない

署名だけでは、正しいWebhookを第三者がコピーして再送するリプレイ攻撃は防げません。そのため、署名対象へタイムスタンプを含めます。

ヘッダーと署名形式を決める

今回は、次のヘッダーを使用します。

Webhookのリクエストヘッダー
Content-Type: application/json
Webhook-Timestamp: 1789689600
Webhook-Signature: v1=6f34...
Webhook-Event-Id: 01K5E4T7X9Q2YF7A8B3C6D0E1F
Webhook-Event-Type: article.published

Webhook-TimestampはUnix時刻の秒、Webhook-Signatureは小文字の16進数とします。v1=は署名方式のバージョンです。

署名対象の文字列は、次の形式にします。

署名対象
{Unix時刻}.{JSONの本文}

タイムスタンプと本文の区切りを決め、送信側と受信側で完全に同じバイト列を使用することが重要です。

送信側で署名を生成する

まず、署名を生成する処理を作成します。

WebhookSigner.cs
using System.Globalization;
using System.Security.Cryptography;
using System.Text;

public static class WebhookSigner
{
    public static string CreateSignature(
        ReadOnlySpan<byte> payload,
        long unixTimestamp,
        string signingSecret
    )
    {
        var timestampBytes = Encoding.ASCII.GetBytes(
            unixTimestamp.ToString(CultureInfo.InvariantCulture)
        );
        var signedPayload = new byte[
            timestampBytes.Length + 1 + payload.Length
        ];

        timestampBytes.CopyTo(signedPayload);
        signedPayload[timestampBytes.Length] = (byte)'.';
        payload.CopyTo(signedPayload.AsSpan(timestampBytes.Length + 1));

        var hash = HMACSHA256.HashData(
            Encoding.UTF8.GetBytes(signingSecret),
            signedPayload
        );

        return $"v1={Convert.ToHexStringLower(hash)}";
    }
}

送信時は、JSONへ変換した後のバイト列に署名します。

WebhookSender.cs
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Globalization;
using System.Text.Json;

public sealed class WebhookSender(
    HttpClient httpClient,
    TimeProvider timeProvider
)
{
    private static readonly JsonSerializerOptions JsonOptions = new(
        JsonSerializerDefaults.Web
    );

    public async Task SendAsync(
        Uri destination,
        object data,
        string eventId,
        string signingSecret,
        CancellationToken cancellationToken
    )
    {
        var payload = JsonSerializer.SerializeToUtf8Bytes(data, JsonOptions);
        var timestamp = timeProvider.GetUtcNow().ToUnixTimeSeconds();
        var signature = WebhookSigner.CreateSignature(
            payload,
            timestamp,
            signingSecret
        );

        using var request = new HttpRequestMessage(HttpMethod.Post, destination);
        request.Content = new ByteArrayContent(payload);
        request.Content.Headers.ContentType = new MediaTypeHeaderValue(
            "application/json"
        );
        request.Headers.Add(
            "Webhook-Timestamp",
            timestamp.ToString(CultureInfo.InvariantCulture)
        );
        request.Headers.Add("Webhook-Signature", signature);
        request.Headers.Add("Webhook-Event-Id", eventId);
        request.Headers.Add("Webhook-Event-Type", "article.published");

        using var response = await httpClient.SendAsync(
            request,
            cancellationToken
        );
        response.EnsureSuccessStatusCode();
    }
}

オブジェクトを署名してから、別の処理で再度JSONへ変換してはいけません。プロパティの順序、空白、改行、文字のエスケープ方法が変わると、意味が同じJSONでも署名は一致しません。

送信するバイト列を先に確定し、その同じバイト列を署名とHTTP本文に使用します。

受信側で元の本文を読み取る

受信側でも、デシリアライズ後のオブジェクトを再びJSONへ変換して署名を確認してはいけません。

リクエスト本文のバイト列をそのまま読み取ります。

WebhookController.cs
using Microsoft.AspNetCore.Mvc;

[ApiController]
public sealed class WebhookController(
    WebhookSignatureVerifier verifier,
    IWebhookEventStore eventStore
) : ControllerBase
{
    [HttpPost("/api/webhooks/articles")]
    public async Task<IActionResult> Receive(
        CancellationToken cancellationToken
    )
    {
        using var buffer = new MemoryStream();
        await Request.Body.CopyToAsync(buffer, cancellationToken);
        var payload = buffer.ToArray();

        var verified = verifier.Verify(
            payload,
            Request.Headers["Webhook-Timestamp"].ToString(),
            Request.Headers["Webhook-Signature"].ToString()
        );

        if (!verified)
        {
            return Unauthorized();
        }

        var eventId = Request.Headers["Webhook-Event-Id"].ToString();
        if (string.IsNullOrWhiteSpace(eventId))
        {
            return BadRequest();
        }

        await eventStore.ProcessOnceAsync(
            eventId,
            payload,
            cancellationToken
        );

        return Ok(new { received = true });
    }
}

署名を検証してからJSONを解析し、業務処理を実行します。不正な本文を先に処理しないようにします。

タイムスタンプと署名を検証する

検証処理では、入力形式、時刻、署名の順に確認します。

WebhookSignatureVerifier.cs
using System.Globalization;
using System.Security.Cryptography;

public sealed class WebhookSignatureVerifier(
    TimeProvider timeProvider,
    IConfiguration configuration
)
{
    private static readonly TimeSpan MaximumAge = TimeSpan.FromMinutes(5);
    private static readonly TimeSpan MaximumFutureSkew = TimeSpan.FromMinutes(1);

    public bool Verify(
        ReadOnlySpan<byte> payload,
        string? timestampValue,
        string? signatureValue
    )
    {
        if (
            !long.TryParse(
                timestampValue,
                NumberStyles.None,
                CultureInfo.InvariantCulture,
                out var unixTimestamp
            )
            || string.IsNullOrEmpty(signatureValue)
            || !signatureValue.StartsWith("v1=", StringComparison.Ordinal)
        )
        {
            return false;
        }

        DateTimeOffset timestamp;
        try
        {
            timestamp = DateTimeOffset.FromUnixTimeSeconds(unixTimestamp);
        }
        catch (ArgumentOutOfRangeException)
        {
            return false;
        }

        var now = timeProvider.GetUtcNow();
        if (
            timestamp < now - MaximumAge ||
            timestamp > now + MaximumFutureSkew
        )
        {
            return false;
        }

        byte[] suppliedSignature;
        try
        {
            suppliedSignature = Convert.FromHexString(
                signatureValue["v1=".Length..]
            );
        }
        catch (FormatException)
        {
            return false;
        }

        var signingSecret = configuration["Webhook:SigningSecret"]
            ?? throw new InvalidOperationException(
                "Webhook signing secret is not configured."
            );
        var expected = WebhookSigner.CreateSignature(
            payload,
            unixTimestamp,
            signingSecret
        );
        var expectedSignature = Convert.FromHexString(
            expected["v1=".Length..]
        );

        return suppliedSignature.Length == expectedSignature.Length &&
            CryptographicOperations.FixedTimeEquals(
                suppliedSignature,
                expectedSignature
            );
    }
}

現在より古すぎるタイムスタンプを拒否することで、記録されたWebhookを後から再送できる期間を制限します。

サーバー間で時計が少しずれる可能性があるため、未来方向にも短い許容範囲を設けています。許容時間は、送信側の再試行間隔やネットワーク遅延に合わせて決めます。

署名は固定時間で比較する

署名を文字列の==で比較すると、不一致になった位置によって処理時間が変わる可能性があります。

避ける比較
return suppliedSignature == expectedSignature;

署名をバイト列へ変換し、CryptographicOperations.FixedTimeEqualsで比較します。

比較前に長さを確認することで、異なる形式やアルゴリズムの値も拒否できます。

イベントIDで重複処理を防ぐ

タイムスタンプが有効期間内なら、同じリクエストを再送できます。また、正しい送信側も、応答を受け取れなかった場合に同じWebhookを再送することがあります。

そのため、署名が正しくてもWebhookは複数回届くものとして処理します。

処理済みイベントの保存例
CREATE TABLE processed_webhook_events (
    event_id text PRIMARY KEY,
    processed_at timestamptz NOT NULL
);

処理を始めるトランザクションでevent_idを登録し、一意制約に違反した場合は処理済みとして扱います。

イベントIDの登録
INSERT INTO processed_webhook_events (event_id, processed_at)
VALUES (@EventId, @ProcessedAt)
ON CONFLICT (event_id) DO NOTHING;

登録件数が0なら、同じイベントはすでに処理されています。

Webhookによるデータ更新とイベントIDの登録は、できる限り同じデータベーストランザクションで行います。別々に保存すると、データ更新だけが成功し、再送時に同じ更新を実行する可能性があります。

シークレットをローテーションする

共有シークレットは、ソースコードやWebhook本文へ含めません。環境変数、シークレット管理サービスなどから読み込みます。

シークレットを安全に切り替えるには、受信側で一時的に新旧2つのシークレットを受け付けます。

  1. 新しいシークレットを受信側へ登録する
  2. 送信側が新しいシークレットで署名する
  3. 古いWebhookの再試行期間が終わるまで両方で検証する
  4. 古いシークレットを無効にする

署名ヘッダーへキーIDを追加すると、どのシークレットで検証するか選択できます。キーIDはシークレットそのものではないため、ヘッダーへ含めても構いません。

検証すべきケース

署名検証には、正常系だけでなく次のテストが必要です。

  • 正しい本文・時刻・署名を受け付ける
  • 本文を1文字変更すると拒否する
  • 異なるシークレットの署名を拒否する
  • v1=がない署名を拒否する
  • 16進数として不正な署名を拒否する
  • 古すぎるタイムスタンプを拒否する
  • 許容範囲を超える未来のタイムスタンプを拒否する
  • 同じイベントIDを複数回処理しない

TimeProviderをコンストラクターから受け取ると、現在時刻を固定して境界値をテストできます。

注意点

JSONをオブジェクトへ変換してから再度シリアライズすると、プロパティ順やエスケープ方法が変わる場合があります。

送信側は送信するバイト列へ署名し、受信側は受信したバイト列をそのまま検証します。

TLSを使用しても、Webhook署名は必要です。TLSは通信経路を保護しますが、公開された受信URLへ別の送信元がリクエストすることまでは防ぎません。

Webhookを安全に処理するには、HMAC署名、タイムスタンプ、固定時間比較、イベントIDによる冪等処理を組み合わせます。それぞれが防ぐ問題は異なるため、署名が一致することだけで処理を完了させないことが重要です。


関連記事