Xserver のサーバーキャッシュで、お問い合わせフォームが送れなくなる理由と対処
Xserver のサーバーキャッシュに PHP のお問い合わせフォームが載ると、全員に同じ CSRF トークン(送信を確かめる「券」)が配られて送れなくなります。対処は Cache-Control: no-store, private を毎回返すこと。session_start の落とし穴と、本番で curl で確かめる方法まで。

Xserver のサーバーキャッシュでお問い合わせフォームが送れなくなる原因は、フォームのページがキャッシュに載り、開いた全員に同じ CSRF トークン(送信を確かめるための「券」)が配られることです。対処は、どの応答でも「Cache-Control: no-store, private」を返すことと、session_start() の前に session_cache_limiter('') を呼んで、その見出しを上書きさせないことです。
公式サイトを WordPress から静的なサイトへ移した時、お問い合わせのフォームは PHP で新しく作りました。ほかのページはただの HTML ですが、このページだけは、開いた人ごとに違う中身を返す必要があります。
ここで引っかかったのが、Xserver の「サーバーキャッシュ」です。私たちのサイトでは、サーバーパネルの設定で、すべてのページがサーバーキャッシュの対象になっていました。何も手を打たないと、お問い合わせのページがキャッシュに載り、ある時から誰も送信できなくなるおそれがありました。
この記事は、その仕組みと、私たちが選んだ対処、本番での確かめ方の記録です。

フォームは、開いた人ごとに違う「券」(CSRF トークン)を配っている
私たちのフォームは、ページを開くたびに、その人だけの「券」(ランダムな文字列)を作ります。券はページの中に隠して入れ、同じものをサーバー側にも、その人の記録(セッション)として覚えておきます。送信された時に、ページから届いた券と、サーバーが覚えている券が一致した時だけ受け付けます。

この券は、ほかのサイトから勝手に送信させる攻撃や、同じ内容を二度送ってしまう事故を防ぐためのものです。人には見えない入力欄(ハニーポット)や、開いてから3秒未満の送信を断る仕組みと組み合わせて、迷惑な送信を減らしています。
キャッシュに載ると、全員に同じ券が配られる
サーバーキャッシュは、一度作ったページの中身をしばらく取っておき、次に開いた人には PHP を動かさずに同じ中身を返す仕組みです。ただの HTML のページなら、表示が速くなるだけで困りません。

ところが、お問い合わせのページがキャッシュに載ると、キャッシュに入った「誰か1人の券」が、そのあと開いた全員に配られます。その人たちのセッションには、その券がありません。送信しても券が一致せず、受け付けられません。
Xserver のマニュアルの決まり
Xserver のマニュアル(サーバーキャッシュ設定)には、キャッシュの対象にしない応答の条件が書かれています。2026年10月11日にマニュアルのページを読んで確かめたところ、次の応答はキャッシュされません。
- 応答の見出し Cache-Control に、「no-cache」「no-store」「private」「must-validate」のどれかが含まれるもの(表記はマニュアルのまま)
- 応答の見出しに Set-Cookie が含まれるもの
- 応答の番号が 200 と 404 以外のもの(転送の 303 などは、もともとキャッシュされない)
ここで、「セッションを始めると Set-Cookie が付くのだから、キャッシュされないのでは」と考えたくなります。けれども、それで守られるのは初めての訪問だけです。
はじめは、.htaccess で /contact/ だけをサーバーキャッシュの対象から外す行も用意していました。けれども、マニュアルにはっきり書いてある Cache-Control の条件で足りるので、その行は外し、PHP の見出しだけに頼ることにしました。設定の場所が1か所になるので、あとから読んだ人にも分かりやすくなります。

session_start が Cache-Control を上書きする落とし穴
PHP で Cache-Control を返すだけなら、header() を1行書けば済みます。ところが、そのあとに session_start() を呼ぶと、PHP はセッションの設定(session.cache_limiter)に従って、自分で Cache-Control などの見出しを送ります。同じ名前の見出しは置き換わるので、先に書いた値が消えてしまいます。


既定の設定なら、送られる値にも no-store が含まれます。ただ、それはサーバーの PHP の設定次第です。キャッシュしてよいという値の設定もあり、そうなっていればフォームはキャッシュに載ります。私たちは、確かめていないサーバーの設定に任せたくありませんでした。
<?php
// どの応答でも、必ず最初にこの見出しを返す(Set-Cookie には頼らない)
header('Cache-Control: no-store, private');
// session_start() が自分の Cache-Control を送って、上の見出しを上書きしないようにする
session_cache_limiter('');
session_name('contact');
session_set_cookie_params([
'path' => '/contact/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
// 開くたびに、その人だけの券を作る
$_SESSION['contact_token'] = bin2hex(random_bytes(16));本番で、券が毎回違うことを確かめる
仕組みを作ったら、本番のサーバーで実際に確かめます。Mac のターミナルから curl で開くだけなので、サーバーに入る必要はありません。
1回目:Cache-Control と Set-Cookie が両方あること
Macで実行curl -s -D - -o /dev/null -c /tmp/c.jar -b /tmp/c.jar https://あなたのドメイン/contact/ | grep -iE '^(cache-control|set-cookie)' cache-control: no-store, private set-cookie: contact=(値); path=/contact/; secure; HttpOnly; SameSite=Lax2回目:Set-Cookie が無くても、Cache-Control があること
クッキーを持ったまま、もう一度開きます。ここで Cache-Control が出なければ、キャッシュに載るおそれがあります。
Macで実行curl -s -D - -o /dev/null -c /tmp/c.jar -b /tmp/c.jar https://あなたのドメイン/contact/ | grep -iE '^(cache-control|set-cookie)' cache-control: no-store, private開くたびに、券が違うこと
クッキーを持たずに2回開き、ページの中の券を見比べます。同じ値が出たら、キャッシュに載っています。
Macで実行for i in 1 2; do curl -s https://あなたのドメイン/contact/ | grep -o 'name="token" value="[^"]*"' | cut -c1-30; done name="token" value="0397edfe02 name="token" value="327ebb0e14 rm /tmp/c.jar
私たちのサイトでも、この3つを本番で確かめました(券の値は、頭の一部だけを載せています)。

まとめ

- サーバーキャッシュに載ったフォームは、全員に同じ券を配り、送信できなくなる
- Xserver のマニュアルでは、Cache-Control に no-store や private がある応答と、Set-Cookie がある応答はキャッシュされない
- Set-Cookie は初めての訪問でしか返らないので、どの応答でも毎回 Cache-Control: no-store, private を返す
- session_start() が見出しを上書きしないよう、先に session_cache_limiter('') を呼ぶ
- 本番では curl で、見出しと、券が毎回違うことを確かめ、最後に1通送る
このフォームを作った、サイト全体の移行の記録はこちらです。
ホームページ制作WordPress から静的サイトへ、社名で検索1位を守ったまま移行した全手順会社のサイトを WordPress から静的サイト(HTML のファイルを置くだけのサイト)へ移した全手順です。社名で検索1位を守るための 301 リダイレクトの洗い出し、バックアップ、誰にも見せないリハーサル、表示を止めない切り替えの順番、すぐ戻せる作りまでを、実際の手順書から書きます。
この記事は、AI(Claude)の支援を受けて作成し、筆者が内容を確認しています。

