「このリクエスト、本当にうちが信用していい相手から来てるんでしたっけ?」——社内のLambdaに外部サービスからのWebhookを受ける口を増やしていたとき、レビューでこの一言が出ました。
正直なところ、最初は手が止まりました。受信したJSONの中身は見ているし、想定したフィールドも入っている。でも「中身が正しそうに見える」ことと「送り主が本物だと検証できている」ことは、まったく別の話なんですよね。中身は誰でも、それらしく作れます。
少し前に、社内のリード獲得基盤やチケット連携まわりで、外部からのWebアクセスを受ける入口がいくつか増えました。外部サービスからのWebhookを受けるLambdaと、社内ツールやエージェントがAPIキー的なトークンを付けて叩いてくるエンドポイント。どちらも「入ってくるリクエストの送り主を、どこまで信用していいか」を判断する必要があります。
このとき私が踏んだ地雷と、最終的に落ち着いた検証の設計を残しておきます。テーマは地味ですが、ここを雑にやると一番まずい部類の穴が空きます。
入口は2種類ある、という整理から始めた
最初にやったのは、コードを書くことではなく入口の分類でした。運用を考えると、検証方式がバラバラだと「どのエンドポイントが何で守られているか」が誰にも分からなくなるからです。
社内の入口は、ざっくり2種類に分けられました。
| 入口の種類 | 送り主 | 信用の根拠にするもの |
|---|---|---|
| 外部サービスからのWebhook | 相手サービス(人間が都度操作しない) | 共有シークレットによるHMAC署名 |
| 社内ツール/エージェントからのAPI呼び出し | 発行済みトークンを持つクライアント | 署名付きトークン(JWT)の検証 |
ここを分けておくと「Webhookは署名、クライアントはトークン」と方針が一本化できて、後から増えた入口もどちらかに振り分けるだけで済みます。逆にこの分類を飛ばして、エンドポイントごとに思いつきで検証を書いていくと、必ず抜けが出ます。
では、それぞれで何にハマったか。
Webhookの署名検証で、全リクエストを弾いた話
外部Webhookの検証は、共有シークレットを使ったHMAC署名で行います。送り主は本文(body)とシークレットからハッシュを計算してヘッダに載せてくる。受け取る側も同じ計算をして、一致すれば「シークレットを知っている相手=本物」と判断する。理屈はシンプルです。
ところが、最初の実装はテスト環境で全リクエストが401になりました。署名が一個も合わない。
原因は、ハッシュを計算する対象を間違えていたことでした。受信ボディを一度パースしてオブジェクトにし、そこから署名計算のためにもう一度文字列化していたんです。これだと、キーの並び順・空白・エスケープのどれかが送り主側とわずかにズレるだけで、ハッシュは完全な別物になります。
署名は「受け取った生のバイト列そのもの」に対して計算しないといけない。当たり前といえば当たり前なんですが、フレームワークが親切にbodyをパースしてくれる環境だと、生の文字列を掴み損ねがちです。
# 最初のダメな実装:パース済みのオブジェクトを再シリアライズしている
import json, hmac, hashlib
def verify_bad(parsed_body: dict, signature: str, secret: bytes) -> bool:
# ここで作り直した文字列は、送り主が署名した元の文字列と
# キー順・空白の時点でもう別物になっている
raw = json.dumps(parsed_body)
digest = hmac.new(secret, raw.encode(), hashlib.sha256).hexdigest()
return digest == signature # しかも == 比較(後述)
直したのは2点です。ひとつは、パースする前の生ボディ(bytes)をそのまま署名計算に渡すこと。もうひとつは、比較を == ではなく定数時間比較にしたこと。
== での文字列比較は、先頭から1文字ずつ照合して違う箇所で打ち切るため、「どこまで一致したか」が応答時間にわずかに漏れます。署名のような秘密に関わる値の比較では、この差を積み重ねて総当たりする攻撃(タイミング攻撃)の入口になり得ます。標準ライブラリに定数時間比較の関数があるので、素直にそれを使います。
# 直した実装:raw body に対して計算し、定数時間で比較する
import hmac, hashlib
def verify_webhook(raw_body: bytes, signature_header: str, secret: bytes) -> bool:
expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
# compare_digest は一致/不一致に関わらず一定時間で返る
return hmac.compare_digest(expected, signature_header)
ちなみに、シークレット自体がbase64などでエンコードされた状態で配布されるサービスもあります。その場合はキーとして使う前にデコードが要る。ここも「文字列のまま鍵にして合わない」で半日溶かしやすいポイントなので、シークレットの形式は最初に確認しておくのが安全です。
JWTは「デコードできた」を「検証できた」と勘違いしやすい
クライアントからのAPI呼び出しは、署名付きトークン(JWT)で検証します。こちらで一番危ないのは、デコードと検証を混同することでした。
JWTはただのbase64url文字列なので、署名を一切確認しなくても中身(ペイロード)は読めてしまいます。デバッグ中に中身を表示して「ちゃんとuser_idが入ってるな」と確認できると、なんとなく検証した気分になる。でもそれは中身を眺めただけで、そのトークンが本物かどうかは何も保証していません。
検証で最低限詰めるべきは、次の4点だと整理しました。
| 検証項目 | 放置すると何が起きるか |
|---|---|
| 署名そのものの検証 | 中身を改ざんしたトークンを通してしまう |
| アルゴリズムの固定(alg) | alg=none や別方式への取り違えで署名検証を素通りされる |
| 有効期限・発行者・対象者(exp / iss / aud) | 期限切れや別用途のトークンを受け入れてしまう |
| ペイロードの妥当性 | 署名は通っても、中の値が空・不正なまま処理が進む |
特に2番目のアルゴリズム固定は見落としやすいところです。検証ライブラリに「許可するアルゴリズム」を明示しないと、トークン側が宣言したアルゴリズムを信じてしまう実装があり、署名なし(alg=none)を有効として受け入れたり、本来は公開鍵で検証すべきものを別の鍵種別として誤って扱わされたりする余地が残ります。検証する側で、受け付けるアルゴリズムを固定するのが鉄則です。
実装は自前で書かず、言語ごとの定番ライブラリ(Pythonなら PyJWT など)に寄せました。署名検証は細部を一つ間違えるだけで穴になるので、ここは自分の手癖より枯れた実装を信用したほうがいい。
# 許可アルゴリズムを固定し、標準クレームまで検証する
import jwt # PyJWT
def verify_token(token: str, secret: str) -> dict:
# algorithms を明示することが肝。ここを省くと検証を素通りされ得る
payload = jwt.decode(
token,
secret,
algorithms=["HS256"], # 受け付ける方式を固定
audience="internal-api", # aud の検証
issuer="xecin-auth", # iss の検証
options={"require": ["exp", "iss", "aud"]},
)
# 署名・期限を通った後で、中身の最低限の妥当性も見る
if not payload.get("sub"):
raise ValueError("subject is empty")
return payload
このコードはまだ改善の余地があります。シークレットを単一の固定値で持っているので、鍵をローテーションしたい場面では「新旧どちらの鍵でも検証を試す」段階的な切り替えができません。運用で鍵を回すなら、検証側を複数鍵対応にするか、いっそ鍵管理をマネージド側に寄せる必要があります。そこは次の課題として残しています。
マネージドに寄せるか、自前で持つか
ここで一度立ち止まって考えたのが、「そもそも自前で検証ロジックを持つべきか」でした。クラウド側には、APIの前段でトークン検証を肩代わりしてくれるマネージドな仕組み(API Gatewayのオーソライザや、ID基盤側での発行・検証)があります。
ざっくりの判断軸はこうでした。コストで言えば、自前検証はLambda内のコードとして動くので追加の固定費はほぼゼロ、対してマネージドのID基盤は月額数千円〜のオーダーで乗ってくることが多い。一方で運用を考えると、鍵のローテーションや失効、監査ログといった「認証まわりの面倒ごと」をマネージド側が引き受けてくれる価値は大きい。
今回は、入口の数がまだ少なく、検証要件もシンプルだったので、いったん自前検証で揃えました。ただし「将来クライアントの種類が増えて鍵管理が重くなったら、マネージドに寄せる」という分岐点だけは、判断理由ごとメモに残しています。ここは規模とチーム体制で答えが変わるところで、好みが分かれる部分でもあります。
検証は一箇所に集める
最後に効いたのは、検証ロジックを各エンドポイントにばら撒かず、入口の手前(ミドルウェア/共通の検証関数)に一箇所だけ置いたことです。
エンドポイントごとに検証を書いていた頃は、新しい口を追加した人が検証を付け忘れる事故が起きかけました。「署名検証を通ったリクエストだけが業務処理に到達する」という関所を一本化しておくと、付け忘れが構造的に起きにくくなります。レビューでも「この口は関所の内側か外側か」だけ見ればよくなる。
運用に乗せるにあたって、受け入れ基準としてこのチェックリストをレビュー項目に入れました。
- Webhookは生ボディに対してHMACを計算し、比較は定数時間で行っているか
- JWTは署名・alg固定・exp / iss / aud・ペイロード妥当性まで検証しているか
- 署名/トークン検証は自前で散らさず、共通の関所を通しているか
- シークレットや鍵はコードに直書きせず、環境変数や鍵管理から読んでいるか
今思えば、最初に「中身が正しそうだから大丈夫」で進みかけたのが一番危ない瞬間でした。受信データの検証(中身が妥当か)と、送り主の認証(本物か)は別物で、後者を飛ばすとどれだけ丁寧に中身を見ても意味がない。
今後は、新しい入口を作る段階で「これはWebhookの関所か、クライアントの関所か」を最初に決めて、検証の付いていない入口がそもそも生まれない形にしていきたいと考えています。認証まわりは派手さがない割に、抜けたときの被害だけは大きいので、ここは慎重に積み上げていくつもりです。もっと良い設計の組み方があれば、ぜひ教えてほしいところです。