TLS 1.3 0-RTT(Early Data)のリスク|リプレイ攻撃とnginx・Node.js実装ガイド

TLS TLS 1.3 0-RTT セキュリティ nginx HTTPS
結論
  • TLS 1.3 0-RTT(early data)は再訪問の1 RTT短縮と引き換えに、リプレイ攻撃の受け口になる。
  • 静的GET中心のCDN配信なら恩恵が大きい一方、ログイン・決済・状態変更が同一ホストで動くなら nginx で ssl_early_data off;、Node.js では maxEarlyDataSize: 0 が安全なデフォルト。
  • 0-RTTを残すなら $ssl_early_data / X-TLS-Early-Data で非冪等リクエストを425拒否し、Anti-Replay トークンべき等性設計をセットで実装する。

TLS 1.3 0-RTT(Early Data)とは

TLS 1.3(RFC 8446)では、従来のTLS 1.2と比べてハンドシェイクが短くなった。初回接続は1-RTT、セッション再開(resumption)時にはクライアントがハンドシェイクの応答を待たずにアプリケーションデータを送れる0-RTT(ゼロラウンドトリップ)モードが追加された。この先送りされる暗号化データを early data(早期データ)と呼ぶ。

【通常の TLS 1.3 再開(1-RTT resumption)】
Client ── ClientHello + PSK ──────────────────► Server
Client ◄── ServerHello + Finished ──────────── Server
Client ── Finished + HTTP Request ────────────► Server  ← ここで初めてHTTP

【0-RTT early data あり】
Client ── ClientHello + PSK + HTTP Request ───► Server  ← ハンドシェイクと同時
Client ◄── ServerHello + Finished + Response ─ Server

ブラウザが再訪問するECサイトやAPIゲートウェイでは、TTFB(Time To First Byte)を数ms〜数十ms削減できる。HTTP/3(QUIC)でも同様の概念があり、HTTP/2とHTTP/3の比較記事でも触れたように、モバイル回線の再訪問では体感差が出やすい。

一方で、IETFのTLS 1.3仕様自体が 0-RTTデータはリプレイされる可能性がある と明記している。これは実装バグではなく、プロトコル設計上のトレードオフだ。性能を取るか、リプレイ耐性を取るかをインフラとアプリケーションの両方で設計しなければならない。

1-RTT・0-RTT・フルハンドシェイクの比較

接続パターン往復(RTT)early data典型レイテンシ感(RTT 80ms)
フルハンドシェイク(初回)1-RTT(TLS 1.3)不可〜80ms + 証明書処理
セッション再開(1-RTT)1-RTT不可(ハンドシェイク後に送信)〜80ms
0-RTT resumption0-RTT(データ先送り)データ送信がハンドシェイクと並行
0-RTTが効くのは「再訪問」だけ

初回訪問者にはPSK(Pre-Shared Key)がないため0-RTTは使えない。CDNキャッシュヒット率が高く、リピーターが多いサイトほど恩恵が大きい。ログイン後のSPAやAPIばかりのサービスでは、効果測定してから有効化を判断すべきだ。

PSKとセッションチケットの仕組み

0-RTTの前提は、前回の接続で交換したセッション情報の再利用だ。

  1. 初回フルハンドシェイク完了後、サーバーは NewSessionTicket(または同等のチケット)をクライアントへ渡す
  2. クライアントはチケットと派生鍵(PSK)を保存する
  3. 再訪問時、ClientHelloに pre_shared_key 拡張と early_data 拡張を付け、HTTPリクエストを暗号化して同時送信する
  4. サーバーはチケットを検証し、妥当ならハンドシェイク完了前にearly dataを復号してアプリケーションへ渡す

nginxでは ssl_session_cachessl_session_tickets がこの再利用を支える。チケットの有効期限(通常は数時間〜1日)が切れると0-RTTは使えず、再び1-RTT以上に戻る。

Early Data リプレイ攻撃 — 何が起きるか

リプレイ攻撃(Replay Attack) とは、正当な通信を傍受し、後から同じデータを再送してサーバーやクライアントを騙す手法だ。TLS 1.3の0-RTTでは、early dataに含まれたHTTPリクエスト(またはQUICフレーム)が、別のTCP/QUIC接続・別の時刻に攻撃者によって再送されても、サーバーが「新しい正当なリクエスト」と区別できない場合がある。

攻撃シナリオの具体例

シナリオA:静的GETのリプレイ(影響小)

  1. ユーザーが GET /assets/logo.png を0-RTTで送信
  2. 攻撃者がパケットをコピーし、1時間後に同じearly dataを再送
  3. サーバーは再度200と画像ボディを返す

キャッシュ可能な静的リソースであれば、副作用はほぼない。CDNのキャッシュヒットと実質同じだ。

シナリオB:状態変更APIのリプレイ(影響大)

  1. ユーザーが POST /api/transfer{ "to": "attacker", "amount": 10000 } を0-RTTで送信(誤設定でearly dataが許可されている
  2. サーバーが処理し送金完了
  3. 攻撃者が傍受した0-RTTパケットを再送
  4. サーバーが再度送金を実行 → 二重送金

このシナリオは べき等性(Idempotency)設計 が未整備だと致命傷になる。0-RTTは「ネットワーク層で再送が起きうる」ことを前提にアプリを組む必要がある。

シナリオC:ログインやセッション確立のリプレイ

  1. POST /login がearly dataで受理される(本来ありえない設定)
  2. リプレイによりセッション確立処理が重複、またはレート制限・ロックアウトを迂回される

認証エンドポイントは常に0-RTT対象外とするのが定石だ。

なぜTLS層だけでは防げないのか

TLS 1.3の0-RTTでは、サーバーは「このearly dataは以前見たコピーか?」を暗号プロトコル単体では完全には判定できない。理由の要点は次のとおり。

観点説明
接続ごとの独立性リプレイは新しいTCP/QUIC接続で行われる。サーバーは正当な再訪問と攻撃的再送を区別しにくい
複数拠点攻撃者が再送するタイミングと、正当なユーザーの再訪問が異なるサーバーインスタンスに当たる可能性がある
仕様上の割り切りRFC 8446はアプリケーション層でのAnti-Replayを求める。TLSは性能優先の道を開いた

したがって「TLS 1.3だからリプレイ安全」は誤り。0-RTTを有効にした時点で、アプリケーションまたはリバースプロキシで防御設計が必須になる。

TLS 0-RTT リスクの要点

early dataに載せたリクエストは、少なくとも1回は意図せず再実行されうると想定せよ。決済・在庫・投票・メール送信など非冪等な操作を0-RTTで受け付ける設計は避ける

リプレイが現実的に成立する条件

すべてのサイトが0-RTTリプレイの標的になるわけではない。攻撃者にとってコストが見合う条件を整理する。

  1. サーバーがearly dataを受理しているssl_early_data on、CDNで0-RTT有効など)
  2. 再送されたリクエストがサーバーで副作用を持つ(POST、状態変更GET、GraphQL mutation)
  3. 傍受可能な経路がある(同一LAN、不正AP、BGPヒジャック、データセンター内の侵害者など)
  4. Anti-Replayが未実装(一度きりトークン、リプレイキャッシュ、Idempotency-Keyなし)

社内VPN上の管理画面や、決済と静的配信をホスト分離している構成では、リスクプロファイルは大きく異なる。同一オリジンにAPIと静的ファイルを載せているモノリスは特に注意が必要だ。

nginxでの ssl_early_data 設定

nginx(1.13.0以降、HTTP/2のearly data対応は1.15.4以降)では ssl_early_data ディレクティブで0-RTTを制御する。デフォルトは off だが、HTTP/3やパフォーマンスチューニングの記事を真似て on にしたまま放置している例が見られる。

0-RTTを無効化する(推奨デフォルト)

http {
    # グローバルで無効化 — 迷ったらこちら
    ssl_early_data off;

    server {
        listen 443 ssl http2;
        listen [::]:443 ssl http2;
        server_name example.com;

        ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_session_cache shared:SSL:50m;
        ssl_session_timeout 1d;
        ssl_session_tickets on;

        # 明示的に off(httpブロックで off なら省略可)
        ssl_early_data off;

        location / {
            proxy_pass http://127.0.0.1:8080;
        }
    }
}

ssl_early_data off; のとき、クライアントが0-RTTでデータを送っても、nginxはハンドシェイク完了まで待ってからリクエストをアプリケーションへ渡す。リプレイ表面はここでほぼ閉じる。

0-RTTを有効化する場合のnginx設定

どうしてもレイテンシを取りにいく場合の例。必ず後述の $ssl_early_data ガードとセットにすること。

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name cdn.example.com;

    ssl_certificate     /etc/ssl/cdn.example.com/fullchain.pem;
    ssl_certificate_key /etc/ssl/cdn.example.com/privkey.pem;

    ssl_protocols TLSv1.3;
    ssl_early_data on;

    # early data 中はリクエストをバッファしない(デフォルト推奨)
    ssl_buffer_size 4k;

  # Anti-Replay: early data では GET/HEAD/OPTIONS のみ許可
    if ($ssl_early_data = 1) {
        set $early_block 0;
        if ($request_method !~ ^(GET|HEAD|OPTIONS)$) {
            set $early_block 1;
        }
        if ($early_block = 1) {
            return 425;  # Too Early (RFC 8470)
        }
    }

    location / {
        root /var/www/static;
        add_header Cache-Control "public, max-age=3600";
    }

    # APIは別 server ブロックで ssl_early_data off 推奨
}
ディレクティブ意味
ssl_early_data on | offTLS 1.3 0-RTTの受理可否
$ssl_early_dataリクエストがearly data由来なら 1、それ以外は空
return 425HTTP 425 Too Early — early dataを拒否する標準的ステータス(RFC 8470)
ssl_buffer_sizeearly data到着時のバッファ。小さくするとメモリ効率が良い

HTTP/3(QUIC)との併用

HTTP/3を有効にしている場合、QUICレイヤでも0-RTTが使われる。

server {
    listen 443 quic reuseport;
    listen 443 ssl;
    http2 on;
    http3 on;

    ssl_early_data on;   # QUIC 0-RTT も影響

    add_header Alt-Svc 'h3=":443"; ma=86400';

    ssl_certificate     /etc/ssl/cdn.example.com/fullchain.pem;
    ssl_certificate_key /etc/ssl/cdn.example.com/privkey.pem;

    location / {
        root /var/www/static;
        add_header Cache-Control "public, max-age=86400";
    }
}

APIオリジンでは ssl_early_data off とし、静的CDNホストだけ on にするホスト分離が運用しやすい。

Node.js での 0-RTT 制御と Early Data 検知

nginx の背後に Node.js アプリを置く構成では、TLS 終端の位置で責任分界が変わる。nginx が TLS を終端する場合、Node には $ssl_early_data 相当の情報を カスタムヘッダーで渡す必要がある。Node 自身が https.createServer で TLS を終端する場合は、OpenSSL の early data API を直接扱う。

nginx → Node.js へ early data フラグを伝える

map $ssl_early_data $http_x_tls_early_data {
    default "";
    1       "1";
}

server {
    listen 443 ssl http2;
    server_name api.example.com;

    ssl_early_data on;

    location / {
        proxy_set_header X-TLS-Early-Data $http_x_tls_early_data;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_pass http://127.0.0.1:3000;
    }
}

Node 側ではこのヘッダーを信頼する前に、nginx 以外から直接到達できないこと(ファイアウォールで 3000 番を閉じる)を確認する。ヘッダーは内部ネットワーク専用の信号だ。

Express ミドルウェア:非冪等メソッドを 425 で拒否

// middleware/rejectEarlyData.js
const NON_IDEMPOTENT = new Set(["POST", "PUT", "PATCH", "DELETE"]);

function rejectEarlyData(req, res, next) {
  const isEarly = req.get("X-TLS-Early-Data") === "1";
  if (isEarly && NON_IDEMPOTENT.has(req.method)) {
    res.set("Retry-After", "0");
    return res.status(425).send("Too Early");
  }
  next();
}

module.exports = { rejectEarlyData };
// app.js
const express = require("express");
const { rejectEarlyData } = require("./middleware/rejectEarlyData");
const { antiReplayToken } = require("./middleware/antiReplayToken");

const app = express();
app.use(express.json());

app.use(rejectEarlyData);
app.use("/api", antiReplayToken);

app.get("/api/health", (req, res) => {
  res.json({ ok: true });
});

app.listen(3000, "127.0.0.1");

Node.js 直接 TLS 終端(early data サイズ制限)

Node が TLS を終端する場合、tls.createSecureContext と ALPN で HTTP/1.1 または h2 を選ぶ。OpenSSL 3 系の Node 20+ では、early data の受理は セッション再開時にクライアントが送ってきたデータ量を maxEarlyDataSize で制限できる(利用可能な場合は 0 にして無効化が最も安全)。

// server-tls.js — API サーバーでは early data を実質無効化
const fs = require("fs");
const https = require("https");
const express = require("express");

const app = express();
app.use(express.json());

app.post("/api/orders", (req, res) => {
  res.status(201).json({ id: "ord_123" });
});

const options = {
  key: fs.readFileSync("/etc/ssl/api.example.com/privkey.pem"),
  cert: fs.readFileSync("/etc/ssl/api.example.com/fullchain.pem"),
  minVersion: "TLSv1.3",
  sessionTimeout: 300,
  sessionIdContext: "api.example.com",
  // early data を受けない: maxEarlyDataSize を 0 に(Node/OpenSSL バージョン要確認)
  maxEarlyDataSize: 0,
};

https.createServer(options, app).listen(443, () => {
  console.log("HTTPS listening on 443 (0-RTT disabled via maxEarlyDataSize=0)");
});

maxEarlyDataSize: 0 が環境で効かない場合は、リクエスト到達前に nginx で ssl_early_data off とする二重防御が確実だ。

Fastify + @fastify/helmet との併用

Fastify でも同様のミドルウェアパターンを preHandler フックで実装できる。

const fastify = require("fastify")({ logger: true });
const NON_IDEMPOTENT = new Set(["POST", "PUT", "PATCH", "DELETE"]);

fastify.addHook("preHandler", async (request, reply) => {
  const isEarly = request.headers["x-tls-early-data"] === "1";
  if (isEarly && NON_IDEMPOTENT.has(request.method)) {
    reply.header("Retry-After", "0");
    return reply.code(425).send({ error: "Too Early" });
  }
});

fastify.get("/api/status", async () => ({ status: "ok" }));

fastify.listen({ port: 3000, host: "127.0.0.1" });

Cloudflare経由の場合

Cloudflareダッシュボードの Network → 0-RTT Connection Resumption がONだと、エッジで0-RTTが有効になる。オリジンnginxの設定と独立しているため、EdgeとOriginの両方を確認すること。キャッシュ可能なGET中心ならEdge 0-RTTの恩恵は大きいが、動的APIを同一ホストで配信しているとリスクが混ざる。

Anti-Replay トークン(反リプレイトークン)の設計

0-RTT を部分的に許可する場合、アプリケーション層の Anti-Replay トークンが最後の防衛線になる。TLS の early data だけでは「同じリクエストが2回届いた」ことを検知できないため、一度きり使える nonce(ワンタイムトークン) または リクエスト指紋の短期キャッシュで重複を弾く。

トークンのライフサイクル

  1. クライアントが GET /api/anti-replay-token通常の1-RTT接続で呼び、サーバーが UUID を発行して Redis に保存(TTL 30〜120秒)
  2. 状態変更リクエストのヘッダー X-Anti-Replay-Token: <uuid> に載せる
  3. サーバーは Redis で GET → 存在すれば DEL(原子的に consume)して処理続行
  4. 同じトークンがリプレイで再送されたら Redis にキーがなく 409 Conflict または 425 Too Early を返す

early data で届いたリクエストは、トークン検証前に 425 で落とすか、トークン必須にして「トークンなし early POST」を拒否する二段構えが安全だ。

Redis による Anti-Replay トークン実装(Node.js 完全例)

// middleware/antiReplayToken.js
const { randomUUID } = require("crypto");
const Redis = require("ioredis");

const redis = new Redis(process.env.REDIS_URL);
const TOKEN_TTL_SEC = 60;
const TOKEN_PREFIX = "anti-replay:";

async function issueAntiReplayToken(req, res) {
  const token = randomUUID();
  await redis.setex(`${TOKEN_PREFIX}${token}`, TOKEN_TTL_SEC, "1");
  res.json({ token, expiresIn: TOKEN_TTL_SEC });
}

function antiReplayToken(req, res, next) {
  const isEarly = req.get("X-TLS-Early-Data") === "1";
  const mutating = ["POST", "PUT", "PATCH", "DELETE"].includes(req.method);

  if (!mutating) {
    return next();
  }

  if (isEarly) {
    res.set("Retry-After", "0");
    return res.status(425).send("Too Early");
  }

  const token = req.get("X-Anti-Replay-Token");
  if (!token) {
    return res.status(428).json({ error: "Anti-Replay token required" });
  }

  const key = `${TOKEN_PREFIX}${token}`;
  redis
    .multi()
    .get(key)
    .del(key)
    .exec()
    .then((results) => {
      const value = results[0][1];
      if (!value) {
        return res.status(409).json({ error: "Token already used or expired" });
      }
      next();
    })
    .catch((err) => {
      req.log?.error(err);
      res.status(503).json({ error: "Replay store unavailable" });
    });
}

module.exports = { issueAntiReplayToken, antiReplayToken };
// routes.js — トークン発行と Idempotency-Key の併用
const { issueAntiReplayToken, antiReplayToken } = require("./middleware/antiReplayToken");

app.get("/api/anti-replay-token", issueAntiReplayToken);
app.post("/api/transfer", antiReplayToken, idempotencyMiddleware, transferHandler);
Anti-Replay と Idempotency-Key の役割分担

Anti-Replay トークンは「この HTTP リクエストを1回だけ実行する」ための短期 nonce だ。Idempotency-Keyべき等性設計)は「同じビジネス操作の再送なら同じ結果を返す」ための長めのキー。0-RTT 環境では 425 拒否 + Anti-Replay トークン + Idempotency-Key の三層が理想だが、API サーバーでは ssl_early_data off だけでも十分なことが多い。

トークン設計のチェックリスト

項目推奨
発行経路必ず 0-RTT 非使用の GET(1-RTT 完了後)
TTL30〜120秒。長すぎるとトークン窃取の窓が広がる
消費GET + DEL を原子的に(Redis MULTI または Lua スクリプト)
スコープユーザー単位・操作種別単位で namespace を分ける
障害時Redis ダウン時は 503 で fail-closed(処理続行しない)
early dataPOST 等は early ならトークン以前に 425

Anti-Replay(反リプレイ)パターン

0-RTTを部分的に残す場合、TLSの外側で同じリクエストの再実行を検出・拒否する仕組みが必要だ。

パターン1:HTTP 425 Too Early で非冪等メソッドを拒否

nginxの $ssl_early_data による メソッド制限は最初の防波堤になる。POST/PUT/PATCH/DELETEは425で落とし、クライアントにハンドシェイク完了後の再送を促す。

map $ssl_early_data $is_early {
    default 0;
    1       1;
}

map "$is_early:$request_method" $reject_early {
    default                    0;
    "1:POST"                   1;
    "1:PUT"                    1;
    "1:PATCH"                  1;
    "1:DELETE"                 1;
}

server {
    ssl_early_data on;

    if ($reject_early) {
        return 425;
    }

    location / {
        root /var/www/static;
        add_header Cache-Control "public, max-age=3600";
    }
}

ブラウザは425を受け取ると、通常は同じリクエストを0-RTTなしで再送する。Fetch APIやXHRの挙動は環境差があるため、自前クライアントではリトライ実装を確認すること。

パターン2:アプリケーション層のリプレイウィンドウ(サーバー側キャッシュ)

サーバー(または共有Redis)に ClientHello内の情報とリクエストのハッシュを短時間保存し、重複を拒否する方式。nginx単体では完結しにくく、アプリまたはOpenSSLカスタムフック、Envoyのようなプロキシで実装される。

概念コード(擬似):

# early data 受理時のみ実行
def accept_early_request(client_random: bytes, req_hash: str) -> bool:
    key = f"early:{client_random.hex()}:{req_hash}"
  # SET NX — 既にあればリプレイ
    if not redis.set(key, "1", nx=True, ex=30):
        raise TooEarlyError("replay detected")
    return True

ウィンドウ長(例:30秒)は PSKの寿命・ロードバランサの粘着性とトレードオフになる。複数サーバー間ではRedisなど共有ストアが必須だ。

パターン3:Idempotency-Key によるべき等化

状態変更APIでは、0-RTTの有無に関わらず Idempotency-Key を必須にする。同じキーで再送されても1回分の副作用に抑えられる。0-RTTリプレイは「クライアントが意図せず同じリクエストを2回送った」ケースと同型に扱える。

POST /api/orders HTTP/1.1
Host: api.example.com
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json

{"sku":"ABC","qty":1}

パターン4:ワンタイムトークン(nonce)

フォーム送信やCSRF対策と組み合わせ、一度きり使えるnonceをearly dataリクエストに要求する。リプレイでは同じnonceが二度使えない。ただしGETにnonceを載せるとキャッシュが効かなくなるため、変更操作に限定する。

パターン5:ホスト・パス分離(インフラAnti-Replay)

最も単純で強力なのは 0-RTTを静的配信ホストだけに限定することだ。

ホストssl_early_data用途
static.example.comon(GETのみ)画像・JS・CSS
api.example.comoffREST/GraphQL
www.example.comoffHTML(動的要素あり)

攻撃面を「キャッシュ可能なGETだけ」に切り詰めると、アプリ層のリプレイ検出の負担が激減する。

0-RTT 環境におけるべき等性(Idempotency)要件

0-RTT を有効にする、または CDN 経由で early data が流通しうる環境では、API のべき等性設計必須のコンプライアンス要件に近づく。通常の「タイムアウト再送」に加え、攻撃者またはネットワークが意図せず同一リクエストを複製する経路が増えるからだ。

エンドポイントごとの要件

エンドポイント種別0-RTT 受理Idempotency-KeyAnti-Replay トークン
静的 GET(CDN)不要不要
参照系 GET(DB読み取り)条件付き可不要不要(副作用なしを確認)
POST 注文・決済不可(425/off)必須推奨
PUT 上書き更新不可推奨推奨
PATCH 部分更新不可必須(累積型更新は特に)推奨
DELETE不可任意(2回目404で足りる場合も)任意
Webhook 受信N/A(サーバー発)イベント ID で必須N/A

実装ルール(0-RTT 対応 API の最低ライン)

  1. 状態変更はすべて Idempotency-Key ヘッダー必須 — キーは操作単位の UUID v4、24時間以上サーバー側に結果キャッシュ
  2. early data 由来の POST は 425 — nginx の $ssl_early_data または Node の X-TLS-Early-Data で判定
  3. 外部決済 API(Stripe 等)へ同じ Idempotency-Key を転送べき等性設計ガイド の Stripe フローと同一キーに揃える
  4. GET で副作用を持たせないGET /export?run=1 のようなトリガー型は POST に変更
  5. 監査ログに early=1 を記録 — インシデント時に 0-RTT 経路か判別できるようにする
// idempotencyMiddleware.js — 0-RTT 再送とリプレイの両方に耐える最小例
async function idempotencyMiddleware(req, res, next) {
  const key = req.get("Idempotency-Key");
  if (!key) {
    return res.status(400).json({ error: "Idempotency-Key required" });
  }

  const cacheKey = `idempotency:${req.method}:${req.path}:${key}`;
  const cached = await redis.get(cacheKey);
  if (cached) {
    const { status, body } = JSON.parse(cached);
    return res.status(status).json(body);
  }

  const originalJson = res.json.bind(res);
  res.json = (body) => {
    redis.setex(
      cacheKey,
      86400,
      JSON.stringify({ status: res.statusCode || 200, body })
    );
    return originalJson(body);
  };
  next();
}
1

同一server_nameで動いているルートをすべて列挙し、副作用のあるメソッドをマークする

2

ssl_early_data on のまま残すルートが GET/HEAD のみか確認する

3

POST等が混ざるなら server ブロックを分割するか ssl_early_data off に切り替える

4

残す場合は 425 拒否・Idempotency-Key・共有リプレイキャッシュのいずれかを実装する

5

ステージングで openssl s_client による再送テストを行い、ログで二重実行が起きないことを確認する

0-RTTを無効化すべきケース

次に当てはまるなら、性能より安全側を選ぶべきだ。

1. 認証・決済・状態変更APIが同一オリジン

ログイン、カート追加、送金、投票、Webhookトリガーなどが example.com 直下にある。ssl_early_data off が無難。TTFBの数msより、二重決済インシデントのコストの方が桁違いに大きい。

2. Anti-Replayをアプリ層まで実装する余力がない

425拒否だけでは「冪等なGETのリプレイ」は防げない。GETが副作用を持つ(例:GET /api/export?trigger=1 でレポート生成)なら、リプレイでコストや情報漏洩が起きうる。実装体制がなければ0-RTTごと切る

3. マルチテナント・マルチリージョンで共有状態が複雑

リプレイウィンドウをRedisで持つ設計は、リージョン跨ぎ・フェイルオーバ時に抜け穴が出やすい。グローバル単一Redisに依存できないチームは、0-RTT無効の方が運用コストが低い。

4. 規制・監査で「再送耐性」の説明責任がある

金融・医療・公共系では、プロトコル仕様上のリプレイリスクをドキュメントで説明し、補償コントロールを示す必要がある。offにして論点を消すのは合理的な選択だ。

5. 0-RTTの効果が計測で小さい

Chrome DevToolsやRUMで、再訪問TTFBの改善が 5ms未満 など誤差範囲なら、リスクを取る意味が薄い。CRP最適化やCDNキャッシュの方が効くことが多い。

運用パターン 0-RTT推奨
静的CDNのみ(GET/HEAD) 有効化可。425・ホスト分離とセット
一般ECサイト(HTML+API同居) 無効化推奨
モバイルAPI(POST多め) 無効化必須に近い
社内管理ツール 無効化。傍受リスクより誤設定リスク
HTTP/3 + 高リピート率メディア 静的ホストのみ有効化を検討

openssl s_client での検証手順

本番やステージングで0-RTTが本当に有効か、無効化設定が効いているかを OpenSSLの s_client で確認できる。テスト用に本番と別ホストを使い、負荷のかかるループは避けること。

前提:OpenSSL 1.1.1 以降

0-RTTには OpenSSL 1.1.1+ が必要。バージョン確認:

openssl version
# OpenSSL 3.x.x または 1.1.1 以上

手順1:フルハンドシェイクでセッションを保存

# セッションをファイルに保存(-sess_out)
openssl s_client \
  -connect example.com:443 \
  -servername example.com \
  -tls1_3 \
  -sess_out /tmp/example.com.session \
  </dev/null

出力で次を確認する:

  • Protocol : TLSv1.3
  • New, TLSv1.3, Cipher is ...
  • 接続末尾付近に NewSessionTicket 相当のメッセージ(OpenSSLの詳細出力 -trace-msg で確認可能)
# より詳細なハンドシェイクログ
openssl s_client \
  -connect example.com:443 \
  -servername example.com \
  -tls1_3 \
  -msg \
  -sess_out /tmp/example.com.session \
  </dev/null 2>&1 | grep -iE "session|ticket|early"

手順2:0-RTT early data 付きで再接続

# 早期データ文字列を送って再開(-early_data は OpenSSL 1.1.1+)
printf 'GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' | \
openssl s_client \
  -connect example.com:443 \
  -servername example.com \
  -tls1_3 \
  -sess_in /tmp/example.com.session \
  -early_data \
  </dev/null

ssl_early_data on のサーバーでは、ハンドシェイク完了前後にHTTPレスポンスの断片が見えることがある。off なら、early dataは無視され、通常の1-RTTフロー後に応答が返る。

手順3:HTTPレスポンスと 425 の確認

APIエンドポイントに対してPOSTのearly dataを試す場合(検証環境のみ):

printf 'POST /api/ping HTTP/1.1\r\nHost: api.example.com\r\nContent-Length: 2\r\n\r\n{}' | \
openssl s_client \
  -connect api.example.com:443 \
  -servername api.example.com \
  -tls1_3 \
  -sess_in /tmp/api.example.com.session \
  -early_data \
  </dev/null 2>&1 | head -40

Anti-Replay設定が効いていれば HTTP/1.1 425 Too Early が期待される。

手順4:curl との比較

curl 7.65.0+ でもTLS 1.3セッション再開は可能だが、early dataの細かい制御は openssl s_client の方が明確なことが多い。

# TLS 1.3 強制・再開の簡易確認
curl -sI --tlsv1.3 --tls-max 1.3 https://example.com/ -o /dev/null -w '%{http_version} %{time_appconnect}\n'

# 2回目(同一プロセスのセッションキャッシュは curl では環境依存)
curl -sI --tlsv1.3 --tls-max 1.3 https://example.com/ -o /dev/null -w '%{time_appconnect}\n'

本番の定常監視には、週次で openssl s_client をCIから叩き、$ssl_early_data がログに出ていないか、意図しない425/200を検知するのも手だ。

nginxアクセスログでの確認

log_format tls_debug '$remote_addr - $request '
                     'early=$ssl_early_data '
                     'proto=$ssl_protocol '
                     'cipher=$ssl_cipher';

access_log /var/log/nginx/tls_early.log tls_debug;

early=1 がPOSTに付いていたら設定ミス。静的GETだけに限定できているか定期レビューする。

HSTS・HTTPS強制との関係

0-RTTは TLS 1.3の上でしか意味を持たない。HTTP→HTTPSの恒久リダイレクトと HSTS により、平文でのearly data議論は通常発生しない。

HSTS Preload まで進めているサイトは、初回からHTTPSのみとなり、SSLストリッピング後の0-RTT悪用リスクも下がる。ただしHSTSはリプレイ攻撃そのものを防がない。Preloadは「HTTPSで接続させる」仕組みであり、0-RTTの受け入れ可否とは独立だ。

推奨の組み合わせ:

  1. HSTS Preload手順に沿ってHTTPSを強制
  2. APIホストでは ssl_early_data off
  3. 静的ホストのみ0-RTTを検討し、GET限定と425を設定
  4. セキュリティヘッダー一覧Strict-Transport-Security と併用

クライアント・ブラウザの挙動

主要ブラウザは、仕様に従い 安全でないと判断したリクエストを0-RTTに載せない 実装が進んでいる。ただし「ブラウザが載せない」と「サーバーが受理しない」は別問題だ。

クライアント0-RTTの傾向
Chrome / Edge再訪問のGET等で0-RTTを試行。サーバーが拒否すれば通常接続にフォールバック
Firefox同様。プライバシー設定や企業ポリシーで差異あり
SafariiOS/macOSでTLS 1.3再開。挙動はバージョン依存
curl / 自前SDK明示的にearly dataを送れる。バックエンド間通信の設定ミスに注意

自前のモバイルアプリやマイクロサービス間TLSで SSL_write_early_data を使う場合は、アプリケーションプロトコル側のリプレイ対策がより重要になる。

ブラウザが0-RTTに載せにくいリクエストの例

仕様と実装の両方から、次のようなリクエストはearly dataに載りにくい(ただしサーバー側でも拒否すべき)。

リクエスト種別0-RTTに載せるべきか
静的アセットの GET(キャッシュ可)載せてもリスク低(リプレイされても同じ200)
GET だがサーバー状態を変える(在庫引当等)載せない
POST / PUT / PATCH / DELETE載せない・受理しない
Cookie付きでセッション確立直後のナビゲーション実装依存。サーバーは ssl_early_data off が無難
認証ヘッダー付きAPIコール載せない

「ブラウザが送らないから安全」は成立しない。curl・モバイルSDK・ボットは制限なくearly dataを送れる。

関連RFCと仕様上の位置づけ

0-RTTを議論するとき、次の文書を参照すると設計判断がブレにくい。

RFC / 文書内容
RFC 8446TLS 1.3。0-RTTと「リプレイ可能であること」を定義
RFC 8470HTTP 425 Too Early。early dataの拒否方法
RFC 9000 / 9001QUICとTLS 1.3の統合。HTTP/3の0-RTT
RFC 9114HTTP/3。QUIC上のセマンティクス

RFC 8446 Section 8 は、アプリケーションが max_early_data_size の範囲で受け取ったデータについて、少なくとも1回のリプレイを想定した設計を要求する。これは「将来の拡張で完全防止する」ではなく、今の時点での公式な割り切りだ。

HTTPレイヤでは RFC 8470 により、サーバーは early data を処理したくない場合に 425 Too Early を返し、クライアントに再試行を促せる。nginxの return 425; はこの標準に沿った応答だ。

ロードバランサ・リバースプロキシでの注意

nginx以外のスタックでも、0-RTTは終端ポイントの設定で決まる。複数層でTLSを終端していると、クライアント↔LBでは0-RTTが効き、LB↔オリジンでは別セッションになる。

AWS Application Load Balancer(ALB)

ALBはクライアントとのTLS 1.3を終端する。セキュリティポリシー ELBSecurityPolicy-TLS13-1-2-... 系ではTLS 1.3が有効だが、0-RTTの扱いはポリシーとリージョンで変わるため、AWSドキュメントで当該ポリシーの 0-RTT 記載を確認すること。オリジンへは通常、新しいTLSまたはHTTP接続で転送されるため、リプレイリスクはALB終端側で評価する。

Envoy

Envoyでは allow_early_data などリスナー・クラスタ設定で0-RTTを制御する。Service Mesh構成では、サイドカー間のmTLSでearly dataが有効になるケースもあり、East-Westトラフィックでもリプレイ設計が必要になる。

# 概念例: early data を拒否する方向
transport_socket:
  name: envoy.transport_sockets.tls
  typed_config:
    "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
    common_tls_context:
      tls_params:
        tls_minimum_protocol_version: TLSv1_3
    # バージョンにより allow_early_data 等のフィールド名を要確認

二重TLS終端(CDN → LB → nginx)

Browser ──0-RTT?──► CDN Edge ──通常TLS──► ALB ──HTTP──► nginx

クライアントが0-RTTで送ったリクエストはCDNで一度復号される。CDNがオリジンへ転送するときに新規HTTPリクエストになるため、オリジンnginxの $ssl_early_data0(空) になりがちだ。つまり Anti-ReplayはCDN層かアプリ層で設計しないと穴が開く。CDNの0-RTTをONにしたなら、CDNのログとWAFルールもセットで見る。

脅威モデリングの簡易フロー

0-RTTを有効にするか迷ったときの判断木だ。

early data を有効にしたい(性能)


副作用のあるAPIは同一ホストか?
   ┌────┴────┐
  YES       NO
   │         │
   ▼         ▼
 off      GET/HEAD のみか?
推奨      ┌────┴────┐
         NO        YES
          │         │
          ▼         ▼
        off    Anti-Replay実装済み?
               ┌────┴────┐
              NO        YES
               │         │
               ▼         ▼
             off    on + 監視・425

多くのWebアプリは左の枝(off)で終わる。静的アセット専用ドメインだけ右側に進む。

よくある誤解

誤解実際
「TLS 1.3だからリプレイ安全」0-RTTは仕様上リプレイ可能
「HTTPSだから傍受されない」エンドユーザー端末・不正AP・内部侵害者は傍受しうる
「ブラウザが守ってくれる」サーバー設定と非ブラウザクライアントがボトルネック
「425を返せば十分」冪等なGETのリプレイや、複数LB間の状態共有は別問題
「HSTS Preloadで0-RTTも安全」HSTS PreloadはHTTPS強制であり、リプレイ対策ではない

トラブルシューティング

425 Too Early が大量に出る

原因: ssl_early_data on なのに、クライアントがPOSTをearly dataで送っている。SPAの初回ナビゲーションとAPIコールが同時に走ると発生しうる。

対処:

  • API用ホストで ssl_early_data off
  • またはクライアントが425を受けたら自動再送する(Fetchは一部ケースで対応)

0-RTTを有効にしたのに効果がない

原因: セッションチケット期限切れ、中間プロキシがTLSを再終端、CDNとオリジンで設定不一致。

確認:

  • openssl s_client -sess_out / -sess_in テスト
  • CDNの「0-RTT」トグルとオリジンnginxの ssl_early_data の両方

early data 有効時に断続的な接続失敗

原因: ssl_buffer_size が極端に小さい、early dataサイズ超過、HTTP/2とHTTP/3の設定競合。

対処: nginxエラーログの SSL_read_early_data 関連を確認。まず ssl_early_data off で切り分け。

チェックリスト(本番投入前)

1

openssl s_client でセッション再開と early data の受理有無を記録する

2

POST/PUT/PATCH/DELETE が early=1 のログに出ないことを確認する

3

決済・認証・Webhookのエンドポイントが ssl_early_data off のホストにある

4

0-RTT有効時は Idempotency-Key またはリプレイキャッシュが状態変更APIを保護している

5

CDN・ロードバランサ・nginxの三層で設定が矛盾していない

6

HSTS(必要なら Preload)でHTTPS強制が完了している

7

0-RTT off 時のTTFB悪化をRUMで許容範囲と確認した

関連記事

この記事は セキュリティ テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。

トピック記事
XSS攻撃と防御の完全ガイドXSS攻撃と防御の完全ガイド|反射型・蓄積型・DOM型の対策
CSRF対策の実装ガイドCSRF対策の実装ガイド|SameSite・Synchronizer Token・Laravel/Expressコード
Double Submit Cookie完全実装Double Submit Cookie完全実装|Synchronizer Token比較・SPA+JWT・サブドメイン攻撃
Content Security Policy (CSP) 設定ガイドContent Security Policy (CSP) 設定ガイド|XSS・クリックジャッキング対策
CSP Report-Only 段階導入ガイドCSP Report-Only 段階導入ガイド|本番を壊さずポリシーを検証する
セキュアなCookie設定ガイドセキュアなCookie設定ガイド|HttpOnly・Secure・SameSiteの使い分けとCSRF対策
CORSエラーの対処法CORSエラーの対処法|「Access-Control-Allow-Origin」で解決する
CORSプリフライトリクエストCORSプリフライトリクエスト|OPTIONSが飛ぶ条件と正しい応答
ローカル開発でCORSエラーが出る原因と3つの回避策ローカル開発でCORSエラーが出る原因と3つの回避策
OAuth2 PKCE SPA実装ガイドOAuth2 PKCE SPA実装ガイド|Authorization Code + S256 + BFFパターン
Passkeys・WebAuthn実装の基礎Passkeys・WebAuthn実装の基礎|FIDO2・Resident Key・@simplewebauthn/server
JWTのデバッグ方法JWTのデバッグ方法|トークンの中身を安全に確認する

まとめ

TLS 1.3の 0-RTT(early data) は、再訪問レイテンシを削る強力な機能だが、リプレイ攻撃リスクをプロトコルが明示的に受け入れている点を理解したうえで設計する必要がある。

やること推奨
迷ったらssl_early_data off;
静的配信のみ別ホストで on + GET限定 + 425
状態変更API0-RTT禁止 + べき等性 + Anti-Replay トークン
Node.jsrejectEarlyData ミドルウェア + maxEarlyDataSize: 0
HTTPS強制HSTS Preload と併用
検証openssl s_client -sess_in -early_data

TLS 0-RTT リスクは「early data リプレイ攻撃」として長尾キーワードでも検索されるが、実務の答えはシンプルだ。副作用のあるリクエストをearly dataに載せない。載せるならアプリでAnti-Replayを実装する。性能数msのためにセキュリティインシデントを引き受ける価値があるか、計測と脅威モデリングのあとで決めればよい。