HMAC署名とは、送信側と受信側が共有する秘密鍵を使って、リクエストの本文が途中で書き換えられていないことを証明する「印」を作るしくみです。Stripeやgithubのwebhook、社内API、そしてAWS LambdaでWebhookを受け取る場合まで、幅広い場面で使われています。この記事ではPythonの標準ライブラリだけでHMAC署名を実装し、受信側での検証の手順と、AWS Lambda関数の中で検証するときの書き方まで、手を動かして確認します。
この記事で分かること
- HMAC署名は、送信側と受信側が共有する秘密鍵でメッセージのハッシュ値を計算し、受信側が同じ計算をして一致を確かめることで改ざんとなりすましを防ぐ方式であること
- 検証の照合には必ずhmac.compare_digestのような定数時間比較を使うこと。AWS LambdaのようなFaaS環境でも、イベントのheaders・body・isBase64Encodedから同じロジックで検証できること
- リプレイ攻撃を防ぐにはタイムスタンプを署名対象に含め、有効期限(5分や10分など)を過ぎたリクエストを拒否すること
対象読者:WebhookやAPIのリクエストを受け取る側で、改ざん・なりすまし・リプレイ攻撃を防ぐ実装をしたいエンジニアの方
執筆:ゆるふわセキュリティ編集部 最終更新日:2026年10月14日
HMACとは: 共有鍵でつくる"封印"
送信側と受信側が同じ秘密鍵を持ち、送信側はメッセージ+鍵からHMAC(署名)を計算して一緒に送ります。受信側は同じ計算をして一致を確認。鍵を知らない攻撃者は正しい署名を作れないので、なりすましと改ざんを同時に防げます。
署名(RSA)との違い
RSAデジタル署名は公開鍵・秘密鍵のペアを使い、誰でも検証できます。HMACは1つの共有鍵を両者が持つ方式で、計算が速く実装も軽い。API内部やwebhookのように「両者が事前に鍵を共有できる」場面で使われます。
HMACの流れ
共有鍵
両者が持つ
署名
本文+鍵で計算
検証
同じ計算で照合
準備
標準ライブラリの hmac・hashlib だけ。追加インストール不要です。
① リクエストに署名する
import hmac, hashlib
SECRET = b"our-shared-secret-key"
def sign(body: bytes) -> str:
return hmac.new(SECRET, body, hashlib.sha256).hexdigest()
body = b'{"amount": 1000, "to": "Bob"}'
signature = sign(body)
print("署名:", signature)送信側はこの signature をHTTPヘッダ(例: X-Signature)に入れて送ります。
② 受信側で検証する
def verify(body: bytes, received_sig: str) -> bool:
expected = sign(body)
return hmac.compare_digest(expected, received_sig) # タイミング攻撃対策
print(verify(body, signature)) # True
# 本文を1文字でも書き換えると…
print(verify(b'{"amount": 9999, "to": "Bob"}', signature)) # False正しい本文と署名の組だけ True。金額を1000→9999に改ざんすると即 False。鍵を知らない攻撃者は、書き換えた本文に合う署名を作れません。
必ず compare_digest で比較
署名の照合に == を使うと、一致した文字数で処理時間が変わり、攻撃者に手がかりを与えます(タイミング攻撃)。hmac.compare_digest は比較時間を一定に保ちます。HMAC検証では必須の作法です。
AWS LambdaのWebhookで検証する
AWS LambdaでWebhookを受け取る構成(Lambda関数URLやAPI Gateway経由)でも、やることは①②と同じです。違うのは、署名とリクエスト本文が自前のHTTPサーバーではなくLambdaのイベント(event)というdict経由で渡ってくる点です。AWSの公式チュートリアルでも、シークレットを環境変数から読み込み、hmac.compare_digestで照合し、不一致なら401を返すという同じ形が示されています1。
import base64
import hashlib
import hmac
import os
def verify_signature(event: dict) -> bool:
secret = os.environ["WEBHOOK_SECRET"].encode() # Lambdaの環境変数から読む
headers = {k.lower(): v for k, v in event.get("headers", {}).items()}
received_sig = headers.get("x-signature", "")
body = event.get("body", "")
# API GatewayやLambda関数URLは、本文がbase64化されて届くことがある
body_bytes = base64.b64decode(body) if event.get("isBase64Encoded") else body.encode()
expected = hmac.new(secret, body_bytes, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received_sig)
def handler(event, context):
if not verify_signature(event):
return {"statusCode": 401, "body": "invalid signature"}
return {"statusCode": 200, "body": "ok"}試しに、正しい署名を付けたイベントと、署名を変えたイベントをそれぞれ渡してみます。
os.environ["WEBHOOK_SECRET"] = "our-shared-secret-key"
body = '{"amount": 1000, "to": "Bob"}'
sig = sign(body.encode())
event_ok = {"headers": {"X-Signature": sig}, "body": body, "isBase64Encoded": False}
event_ng = {"headers": {"X-Signature": "deadbeef"}, "body": body, "isBase64Encoded": False}
print(handler(event_ok, None))
print(handler(event_ng, None)){'statusCode': 200, 'body': 'ok'}
{'statusCode': 401, 'body': 'invalid signature'}本文をJSONとして読み直さない
event["body"]を一度json.loadsでパースしてから再度json.dumpsで文字列化すると、キーの並び順やスペースの有無で元のバイト列と変わってしまい、署名が一致しなくなります。検証には送られてきた本文の生バイト列をそのまま使ってください。
③ リプレイ攻撃も塞ぐ
HMACは改ざんを防ぎますが、「正しいリクエストをそのまま再送する」リプレイ攻撃は防げません。実務ではタイムスタンプを署名対象に含め、古いリクエストを弾きます。
import time
def sign_with_ts(body: bytes):
ts = str(int(time.time())).encode()
sig = hmac.new(SECRET, ts + b"." + body, hashlib.sha256).hexdigest()
return ts.decode(), sig
def verify_with_ts(body, ts, sig, max_age=300):
if abs(time.time() - int(ts)) > max_age: # 5分より古ければ拒否
return False
expected = hmac.new(SECRET, ts.encode() + b"." + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)
ts, sig = sign_with_ts(body)
print(verify_with_ts(body, ts, sig)) # True(発行直後なので有効)タイムスタンプごと署名するのがポイント。署名対象にタイムスタンプを含めれば、それ自体も改ざんできません。
有効期限は何分にすればよいか
max_ageに何秒を入れるべきかは、実装者が最初に悩むポイントです。上のコードはmax_age=300(5分)にしていますが、これは多くのサービスが採用している目安の値です。Stripeのライブラリはタイムスタンプと現在時刻の許容差をデフォルトで5分に設定しており、サーバーの時計をNTPで正確に保つことも合わせて推奨しています2。Webhook配信の遅延やサーバー間の時計のズレが大きい環境では、10分(max_age=600)まで広げている例もあります。
# 配信の遅延やクロックのズレが気になる場合は、10分まで許容幅を広げる
print(verify_with_ts(body, ts, sig, max_age=600)) # True短い有効期限(例: 1分)
- リプレイ攻撃の再送できる時間が短い
- 時計のズレや配信遅延で正規リクエストまで弾きやすい
長い有効期限(例: 10分以上)
- 配信遅延や多少の時計のズレに強い
- 盗聴されたリクエストが再送できる時間も長くなる
タイムスタンプより先に署名を確認する
タイムスタンプの値そのものは、署名を検証するまで信用できません(攻撃者が自由に書き換えられるため)。verify_with_tsのように、まずHMAC全体を照合し、そのうえでタイムスタンプの新しさを確認する順番を守ってください。
まとめ
- HMAC=共有鍵でメッセージに署名し、受信側が同じ計算で検証する
- 改ざん(本文の書き換え)となりすまし(鍵を知らない相手)を同時に防ぐ
- 照合は
hmac.compare_digestでタイミング攻撃を防ぐ - AWS Lambdaのようなイベント駆動の環境でも、headers・body・isBase64Encodedから同じロジックで検証できる
- リプレイ攻撃はタイムスタンプを署名に含めて弾く。有効期限は5分が目安だが、配信遅延が大きい環境では10分まで広げることもある
よくある質問
Q. HMACの鍵(SECRET)はどこに保存すればいい?
A. コードに直書きせず、環境変数やシークレット管理サービスに保存します。AWS Lambdaであれば環境変数のほか、AWS Secrets Managerのような専用サービスで管理し、定期的にローテーションするのが安全です。
Q. 有効期限を過ぎたリクエストは、具体的にどう扱えばいい?
A. 署名の検証自体は通っても、verify_with_tsがFalseを返すのでリクエストを拒否し、401などのエラーを返します。古いリクエストを再送された場合の正常な挙動なので、ログに残しておくと不正利用の調査に役立ちます。
Q. 送信側と受信側で鍵がずれていると、どんなエラーになる?
A. 例外にはならず、compare_digestがFalseを返すだけです。原因の多くは、環境変数の設定ミスや、鍵をローテーションした際に片方だけ更新し忘れていることです。
📚 もう少し詳しく(背景と理論)気になる人向け・タップで開く
HMAC は Bellare・Canetti・Krawczyk (1996) が提案し RFC 2104 で標準化された、ハッシュ関数に鍵を組み込むメッセージ認証方式です3。単に hash(secret + message) とするのは危険で、多くのハッシュ関数(MD5/SHA-1/SHA-256 の系列)は長さ拡張攻撃を許してしまいます。
HMAC は内側・外側の2段構えでこれを防ぎます4。検証時は必ず hmac.compare_digest のような定数時間比較を使うこと——通常の == は不一致箇所で早期に返るため、応答時間の差から署名を推測するタイミング攻撃の糸口になります5。
AWS LambdaでWebhookを受ける構成でも考え方は同じで、AWSの公式チュートリアルも環境変数に保存した鍵とcompare_digestによる定数時間比較を使う実装を示しています1。
この記事のコードをまとめて動かす
この記事のコードは、そのまま実行できる形でGitHubに置いています。手元で動かして数字を変えてみると、理解が早くなります。
- jumpei1228/security-hmac-api-python(MITライセンス)
次の一歩 🌸
公開鍵方式の署名はRSAデジタル署名の実装、通信の暗号化はHTTPSのしくみ、Webの弱点はWebの脆弱性へどうぞ。
参考資料・出典
- RFC 2104: HMAC: Keyed-Hashing for Message Authentication | IETF DatatrackerHMACの原論文であり標準化文書。内外2重のハッシュ構造と長さ拡張攻撃への耐性を定義している
- Tutorial: Creating a webhook endpoint using a Lambda function URL | AWS DocumentationAWS LambdaでWebhookの署名を検証する公式チュートリアル。環境変数に保存した鍵とcompare_digestによる照合を示す(本文は検索結果の要約で確認、公式ページ本文では未確認)
- Timestamp Tolerance | Svix GlossaryWebhook署名のタイムスタンプ許容差について、短い場合と長い場合のトレードオフを解説(検索結果の要約で確認)
ゆるふわポータルでは、一次情報・公的資料にもとづき、専門用語をできるだけやさしく翻訳することを心がけています。内容に誤りや古い情報がある場合は、お問い合わせからご連絡ください。
執筆:ゆるふわセキュリティ編集部 最終更新日:2026年10月14日
Footnotes
-
AWS Documentation, "Tutorial: Creating a webhook endpoint using a Lambda function URL"。シークレットを環境変数から読み込み、
hmac.compare_digestで照合し、不一致なら401を返す実装を示している。 ↩ ↩2 -
Stripeの公式ライブラリは、署名検証時のタイムスタンプ許容差をデフォルト5分に設定している。Svixのタイムスタンプ許容差に関する解説でも、短すぎる許容差は時計のズレや配信遅延で正規のリクエストを弾きやすく、長すぎるとリプレイ攻撃に弱くなるというトレードオフが説明されている。 ↩
-
Krawczyk, Bellare & Canetti (1997). RFC 2104 "HMAC: Keyed-Hashing for Message Authentication." 内外2重のハッシュで鍵付き認証を実現。 ↩
-
素の
hash(key‖msg)は Merkle–Damgård 構造のハッシュで長さ拡張攻撃を許す。HMAC の二段構造はこれを防ぐため、自前結合よりHMACを使う。 ↩ -
compare_digestは全バイトを比較してから返す定数時間比較。通常比較は差異位置で処理時間が変わり、タイミング攻撃の手がかりになる。 ↩
