AWS WAF の JavaScript Integrations と ATP でクレデンシャルスタッフィング対策をやってみた

記事タイトルとURLをコピーする

はじめに

こんにちは、アプリケーションサービス本部ディベロップメントサービス 1 課の森山です。

先日開催された、AWS Summit Japan 2026 に参加してきました! AWS Summit への参加は 2 回目、そして 2 日ともの参加は初めてでした。

これから数回に分けて、AWS Summit で学んだことをアウトプットしていきます! 新機能かどうかは関係なく、気になったことや興味が湧いたことを記載していく予定です。

今回はブースで見た、AWS WAF JavaScript Integrations について検証してみます。

このブースでは BOT コントロールのユースケースとして紹介されていましたが、今回はミニマムな動作検証がしたかったので、少し違う切り口で試してみることにしました。

漏洩した ID・パスワードのリストを使って、ログイン API に総当たりでリクエストを送りつける「クレデンシャルスタッフィング」対策のユースケースで動作確認してみます。

この攻撃の特徴は、ブラウザでログインページを開かず、curl やスクリプトから API のエンドポイントに直接リクエストを送りつけられる点です。 正規のログインページを経由せずに直接 API を叩かれてしまうと、フロントエンド側でどれだけ頑張っても防ぎようがありません。

これをAWS WAF の JavaScript Integrations と、Account Takeover Prevention(ATP)というマネージドルールグループの組み合わせで対処してみます。

JavaScript Integrations は「ブラウザで JS を実行した証明(トークン)を持っているリクエストだけを通す」という仕組みらしく、これがあれば curl での直接アクセスを弾けるのでは、と思い実際に検証してみることにしました。

この記事で学べること

  • AWS WAF JavaScript Integrations の基本的な仕組み
  • ATP(Account Takeover Prevention)ルールグループの設定方法
  • JS 統合ありなしでのリクエストの挙動の違い
  • ATP 単体では防げない「トークン無し」リクエストへの対処法
  • WAF トークンの有効期限とリプレイ実験の結果

前提知識・条件

  • 検証日: 2026 年 7 月 3 日
  • リージョン: アジアパシフィック(東京)リージョン(ap-northeast-1)
  • WAF の Web ACL: 一部を AWS CDK(Cloud Development Kit、TypeScript)で構築

AWS WAF JavaScript Integrations と ATP とは?

AWS WAF の JavaScript Integrations は、保護対象のページに専用スクリプト(challenge.js)を埋め込むことで、リクエストがブラウザから正しく送られたものかを検証する仕組みです。

ページを開いた時点でサイレントにチャレンジが実行され、成功すると WAF トークンが発行されます。以降のリクエストにはこのトークンが自動的に付与されるようになる、というイメージですね。

docs.aws.amazon.com

ATP(Account Takeover Prevention)は AWS WAF のマネージドルールグループの一つで、ログインページを狙ったクレデンシャルスタッフィングやブルートフォース攻撃を検知するために作られています。

漏洩クレデンシャルとの一致チェックや、同一 IP・セッションからの高頻度アクセス検知など、ログインに特化したルールがまとまっている模様です。

docs.aws.amazon.com

検証の全体構成

今回作ったハンズオン環境の全体像はこんな感じです。

ログイン API は検証ロジックを持たない完全なモックで、常に成功レスポンスを返します。

あくまで「正規のブラウザ経由のリクエストか、それ以外か」を WAF がどう見分けるかを確認するための最小構成です。

やってみた

Step 1: 疑似ログイン API(AWS Lambda + Amazon API Gateway)を作成

ログイン API 役の AWS Lambda 関数と、その前段の Amazon API Gateway をまとめて AWS CDK で作ります。

github.com

Lambda は認証ロジックを持たせず、常に成功を返すだけのモックです。 また、後述する Amplify Hosting(別オリジン)からの fetch を通すために CORS ヘッダーも付与しています。

import json


def handler(event, context):
    # 検証ロジックは持たず、常に成功を返すモック
    try:
        payload = json.loads(event.get("body") or "{}")
    except json.JSONDecodeError:
        payload = {}

    username = payload.get("username", "unknown")

    return {
        "statusCode": 200,
        "headers": {
            "Content-Type": "application/json",
            # Amplify Hosting(別オリジン)からのfetchを許可するためCORSヘッダーを付与
            "Access-Control-Allow-Origin": "*",
        },
        "body": json.dumps({"message": f"login ok (mock) for {username}"}),
    }

続いて Lambda の前段に API Gateway(REST API)を置きます。

/login リソースに POST メソッドを追加し、統合タイプは先ほどの Lambda 関数を指定しました。

ステージ名を prod としてデプロイすると、以下のようなエンドポイント URL が発行されます。

https://xxxx.execute-api.ap-northeast-1.amazonaws.com/prod/login

このステージが、次の Step で作る WAF の保護対象になります。

この時点では WAF をまだ何も付けていないので、curl で直接叩いてもそのまま通ります。

curl -i -X POST "https://xxxx.execute-api.ap-northeast-1.amazonaws.com/prod/login" \
  -H "Content-Type: application/json" \
  -d '{"username":"test@example.com","password":"password123"}'
HTTP/2 200
content-type: application/json
{"message": "login ok (mock) for test@example.com"}

Step 2: WAF Web ACL を作成し、ATP を設定

続いて WAF Web ACL を作り、API Gateway に関連付けます。こちらも Step 1 と同じく AWS CDK で構築します。

rules には ATP(AWSManagedRulesATPRuleSet)のマネージドルールグループを追加します。

ポイント: ATP だけでは「トークン無し」を単体でブロックしない

この時点で動作確認をしましたが、curl をブロックしてくれません。

調べてみると、ATP の各ルールの検知条件は以下のようなもので、「トークンが無いこと」自体を単体でブロックする条件が存在しないことが分かりました。

  • VolumetricIpHigh / VolumetricSession: 同一 IP・同一セッションからの高頻度リクエスト
  • AttributeCompromisedCredentials: 同一セッションから漏洩クレデンシャルを使ったリクエストが複数回送信された場合に検知
  • AttributeUsernameTraversal / AttributePasswordTraversal: ID・PW を変えながらの総当たり
  • SignalMissingCredential: リクエストボディに username・password フィールドが無い

docs.aws.amazon.com

つまり ATP は「ブルートフォースや漏洩クレデンシャルの利用」を検知するルールであり、「JS 統合を経由したか(トークンの有無)」を判定するルールではない模様です。

公式ドキュメントでも、ATP が付与するトークン状態ラベルのうち rejected(トークンはあるが無効)は自動でブロックする一方、awswaf:managed:token:absent(トークンが無い)は自動ではブロックしないと明記されていました。

The AWSManagedRulesATPRuleSet blocks requests that have the rejected token label, but it doesn't block requests with the absent token label.

docs.aws.amazon.com

対処法として、awswaf:managed:token:absent ラベルにマッチするカスタムルールを、ATP の後(優先度を下げて)追加し、明示的にブロックすることにしました。

同じ公式ドキュメントの「To block requests that are missing tokens when using the Bot Control or ATP managed rule group」という項目に、同じ内容がサンプルコード付きで書かれていました。これは AWS が推奨する対処法のようです。

With the Bot Control and ATP rule groups, it's possible for a request without a valid token to exit the rule group evaluation and continue to be evaluated by the protection pack (web ACL). To block all requests that are missing their token or whose token is rejected, add a rule to run immediately after the managed rule group to capture and block requests that the rule group doesn't handle for you.

// ATP: priority 0, overrideAction: none (ブルートフォース・漏洩クレデンシャル検知)
// カスタムルール: priority 1, トークン欠如(token:absent)ラベルでBlock
{
  name: 'BlockMissingTokenRule',
  priority: 1,
  action: { block: {} },
  statement: {
    labelMatchStatement: { scope: 'LABEL', key: 'awswaf:managed:token:absent' },
  },
  // ...
}

Step 3: JavaScript Integration の設定・取得

続いて JS 統合本体の設定です。

WAF コンソールの左ナビゲーションペインから 「アプリケーション統合」 を選択し、「インテリジェントな脅威統合」タブを開きます。

ATP を使っている Web ACL(waf-js-atp-login-web-acl)を一覧から選択すると、Integration URL(challenge.js の読み込み元スクリプトタグ)が表示されるので、これを控えておきます。

発行される script タグは以下のような内容です。

<script type="text/javascript" src="https://xxxx.token.awswaf.com/xxxx/challenge.js" defer></script>

Step 4: ログイン HTML(JS 統合あり)を作成し、Amplify Hosting で公開

このスクリプトタグを埋め込んだログインページを作ります。ただし、今回は AWS Amplify Hosting でホスティングすることにしました。

challenge.js の URL と API のエンドポイント URL は、それぞれ Step 3、Step 1 で控えたものに置き換えます。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>ログイン(JS統合あり)</title>
  <!-- Step3で取得したIntegration URLに置き換える -->
  <script src="https://xxxx.token.awswaf.com/xxxx/challenge.js" defer></script>
</head>
<body>
  <h2>ログイン(JS統合あり)</h2>
  <input id="username" placeholder="username" value="test@example.com"><br>
  <input id="password" type="password" placeholder="password" value="password123"><br>
  <button onclick="login()">ログイン</button>
  <pre id="result"></pre>

  <script>
    const API_URL = "https://xxxx.execute-api.ap-northeast-1.amazonaws.com/prod/login"; // Step1のURLに置き換え

    async function login() {
      const body = JSON.stringify({
        username: document.getElementById("username").value,
        password: document.getElementById("password").value
      });

      // AwsWafIntegration.fetch がWAFトークンを自動付与してリクエストする
      const res = await AwsWafIntegration.fetch(API_URL, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: body
      });

      const text = await res.text();
      document.getElementById("result").textContent =
        `Status: ${res.status}\n${text}`;
    }
  </script>
</body>
</html>

ポイントは AwsWafIntegration.fetch を使っているところです。

通常の fetch の代わりにこれを使うことで、WAF トークンが自動的にヘッダーに付与されます。

Step 5: WAF 側で Token domain に Amplify のドメインを追加

このままだと challenge.js が Amplify のドメイン向けの有効なトークンを発行できません。

Token domain list を指定しない場合、WAF は保護対象リソースのドメインのトークンのみ受理するとのことなので、Amplify のような別ドメインを使う場合は忘れずに設定する必要があります。

docs.aws.amazon.com

こちらも CDK の CfnWebACLtokenDomains プロパティで設定します。

const webAcl = new wafv2.CfnWebACL(this, 'LoginWebAcl', {
  // ...
  tokenDomains: ['xxxx.amplifyapp.com'],
  // ...
});

最終的な WAF の CDK ソースは以下です。

github.com

Step 6: 動作確認

Amplify で発行された URL にブラウザでアクセスすると、ページを開いた時点で裏側で challenge.js がサイレントチャレンジを実行します。(ユーザー操作は不要です。)

「ログイン」ボタンを押すと AwsWafIntegration.fetch がトークンを自動付与してリクエストし、レスポンスが 200 login ok (mock) になれば成功です。

x-aws-waf-token の存在も確認できますね。

一方、改めて curl で直接叩いてみると、今度はきちんと 403 が返ってきました。

curl -i -X POST "https://xxxx.execute-api.ap-northeast-1.amazonaws.com/prod/login" \
  -H "Content-Type: application/json" \
  -d '{"username":"test@example.com","password":"password123"}'
HTTP/2 403
content-type: application/json
x-amzn-errortype: ForbiddenException
{"message":"Forbidden"}

ブラウザ経由(JS 統合あり)は通過し、トークンを持たない curl の直接リクエストは 403 でブロックされる、狙っていた挙動を確認できました。

補足: WAF トークン(x-aws-waf-token)の中身と有効期限

AwsWafIntegration.fetch が自動付与するトークンは、リクエストヘッダー x-aws-waf-token として送信されます。

トークンの構造は概形としてはこんな感じです。

<セッションID(UUID)>:<短いメタデータ>:<暗号化されたペイロード>

暗号化ペイロードの中身自体は非公開ですが、公式ドキュメントによると以下のような情報が含まれるとされています。

  • silent challenge への最新の成功応答のタイムスタンプ
  • CAPTCHA への最新の成功応答のタイムスタンプ(CAPTCHA を使っている場合のみ)
  • クライアントの識別子や、自動化の兆候・ブラウザ設定の不整合などの信号(個人を特定できない形で収集される)
  • JS 統合 SDK 使用時は、マウス移動・キー入力・ページ上のフォーム操作といったインタラクティビティ情報もパッシブに収集され、トークンに含まれる

docs.aws.amazon.com

有効期限(immunity time)については、公式ドキュメントで以下のように説明されていました。

docs.aws.amazon.com

  • Web ACL のデフォルト設定は 300 秒(5 分)。challenge immunity time の最小値も 300 秒(CAPTCHA immunity time の最小値は 60 秒、両者とも最大は 3 日)
  • 免除期間内は同じトークンが再利用される(毎リクエストごとに変わるわけではない)
  • immunity time が切れると、裏で自動的に再チャレンジが走り、セッション ID 部分も含めて新しいトークンに切り替わる

実際に開発者ツールで 2 回連続してヘッダーを確認したところ、完全に同一の値でした。immunity time 内だったからですね。

実験: トークンを curl に手動で付けて再送(リプレイ)してみる

次にブラウザで発行された x-aws-waf-token の値をコピーして、curl のヘッダーに手動でセットして送るとどうなるか実験してみました。

curl -i -X POST "https://xxxx.execute-api.ap-northeast-1.amazonaws.com/prod/login" \
  -H "Content-Type: application/json" \
  -H "x-aws-waf-token: <ブラウザから取得した直後のトークン>" \
  -d '{"username":"test@example.com","password":"password123"}'

結果は 200 で通過しました。

HTTP/2 200
{"message": "login ok (mock) for test@example.com"}

これで分かったのは、WAF トークンの検証で見ているのは、送信元がブラウザか curl かというクライアントの種類そのものではない、ということです。

公式ドキュメントによると、トークンが有効(accepted)と判定されるには、有効期限(タイムスタンプ)だけでなく、署名の妥当性や Token domain の一致なども含めて評価されるとのことでした。

つまり ATP が守っているのは「ブラウザで challenge.js を実行し、トークンを取得する能力があるか」であり、いったん有効なトークンを取得できれば、その後の送信手段(ブラウザ、curl、スクリプト等)は問われないようです。

攻撃者がブラウザ操作で一度トークンを取得できれば、その後は非ブラウザのツールでリプレイして大量リクエストを送ることも可能ですね。

この対策は主に ATP 側のボリューム攻撃検知(VolumetricIpHigh / VolumetricSession)や、同一トークンが複数の IP から使われた場合の検知(トークンの使い回し検知)が担っているとのことでした。

docs.aws.amazon.com

JS 統合はあくまで「入口のハードルを上げる」対策であり、それ単体で全ての攻撃を防ぐわけではない、という点は覚えておく必要がありそうです。

まとめ

今回は AWS WAF の JavaScript Integrations と ATP を組み合わせて、クレデンシャルスタッフィング対策の動作確認をしてみました。分かったことをまとめると、こんな感じです。

  • JS Integrations は「ブラウザで JS を実行した証明(トークン)」の有無を判定する仕組み
  • ATP 単体では「トークンが無いこと」自体をブロックしないため、awswaf:managed:token:absent ラベルを見るカスタムルールの追加が必要
  • いったん有効なトークンを取得できれば、その後の送信手段(ブラウザ・curl・スクリプト等)は問われない
  • トークンのリプレイ対策は、ATP のボリューム攻撃検知(VolumetricIpHigh / VolumetricSession)や、同一トークンの複数 IP からの使い回し検知が担っている

なお、JS Integrations や ATP はあくまで「ブラウザで正しく実行されたリクエストか」を見分ける仕組みであり、認証そのものの代替にはならない点は注意が必要かなと思いました。ID・パスワード認証や MFA などの既存の対策と組み合わせて使うのが前提になりそうです。

誰かのお役に立てると幸いです。最後まで読んでいただきありがとうございました〜!

森山 智史 (記事一覧)

アプリケーションサービス本部ディベロップメントサービス1課

2025年10月中途入社。

AWS Community Builders Serverless 2026

All AWS Certification Engineers 2026