レスポンスボディ由来とレスポンスヘッダー由来で整理する
はじめに
htmx で hx-post や hx-delete を使うと、HTML から手軽に HTTP リクエストを送信できます。
<button hx-post="/items/1/publish">
公開する
</button>
ただし、ログイン済みユーザーの状態を変更する処理では CSRF 対策が必要です。
CSRF トークンの扱い方で迷いやすいのは、次の違いです。
1. レスポンスボディに CSRF トークンを含める
2. レスポンスヘッダーに CSRF トークンを含める
この記事では、htmx でこの2つをどう扱えばよいのかを整理します。
対象読者
この記事は、次のような人を対象にしています。
- htmx で
hx-post/hx-deleteを使い始めた人 - CSRF トークンを hidden input で送るか、HTTP ヘッダーで送るか迷っている人
- サーバーサイドとフロントエンドの責務分担を整理したい人
- htmx を使ったフォーム送信や管理画面の実装を考えている人
- CSRF 対策を、HTML 側に寄せるべきか JavaScript 側に寄せるべきか判断したい人
この記事では、CSRF の基本概念そのものよりも、htmx における CSRF トークンの置き場所と扱い方を整理します。
そのため、HTML フォーム、HTTP リクエスト、HTTP ヘッダー、JavaScript の基本的な役割をある程度知っている読者を想定します。
結論
フォーム中心なら、まずは hidden input がわかりやすいです。
<form hx-post="/profile" hx-target="#result">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<input name="display_name">
<button>保存</button>
</form>
<div id="result"></div>
一方で、フォーム外の hx-post や hx-delete が増える場合は、CSRF トークンを JavaScript 側で扱い、htmx のリクエストヘッダーに自動で付けるほうが管理しやすくなります。
レスポンスボディ由来:
hidden input / meta から読む
レスポンスヘッダー由来:
htmx:afterRequest で読む
htmx:configRequest で次回リクエストに付ける
1. レスポンスボディ由来:hidden input で送る
もっとも HTML フォームらしい方法は、フォーム内に hidden input を入れることです。
<form hx-post="/settings" hx-target="#message">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<label>
サイト名
<input name="site_name">
</label>
<button>保存</button>
</form>
<div id="message"></div>
この場合、CSRF トークンはフォームの送信値としてサーバーに送られます。
_csrf=...&site_name=...
通常のフォーム送信と同じ考え方なので、フォーム中心の画面ではこの方法が単純です。
2. レスポンスボディ由来:meta から読んでヘッダーに付ける
フォーム外のボタンから hx-post する場合は、meta に置いた CSRF トークンを JavaScript で読んで、リクエストヘッダーに付ける方法もあります。
<meta name="csrf-token" content="{{ csrf_token }}">
<button hx-post="/items/1/publish" hx-target="#status">
公開する
</button>
<div id="status"></div>
<script>
document.body.addEventListener("htmx:configRequest", (event) => {
const token = document.querySelector('meta[name="csrf-token"]')?.content;
if (token) {
event.detail.headers["X-CSRF-Token"] = token;
}
});
</script>
この方式では、htmx のリクエストに次のようなヘッダーが付きます。
X-CSRF-Token: ...
フォームではなく、ボタン単体で状態変更する UI に向いています。
3. レスポンスボディ由来:hidden input を hx-include で含める
ページ共通の hidden input を使いたい場合は、hx-include で含めることもできます。
<input type="hidden" id="csrf-token" name="_csrf" value="{{ csrf_token }}">
<button
hx-post="/items/1/like"
hx-include="#csrf-token"
hx-target="#like-result">
いいね
</button>
<div id="like-result"></div>
この場合、CSRF トークンはリクエストボディのパラメーターとして送られます。
_csrf=...
ただし、ボタンが増えると hx-include の指定漏れが起きやすくなります。
小さな画面では十分ですが、状態変更ボタンが増える画面では、共通 JavaScript でヘッダーに付ける方式のほうが管理しやすいです。
4. レスポンスヘッダー由来:htmx:afterRequest で受け取る
サーバーが CSRF トークンをレスポンスヘッダーで返す場合を考えます。
X-CSRF-Token: next-token
この値は、htmx のリクエスト完了後に htmx:afterRequest で受け取ります。
<script>
let csrfToken = null;
document.body.addEventListener("htmx:afterRequest", (event) => {
const token = event.detail.xhr.getResponseHeader("X-CSRF-Token");
if (token) {
csrfToken = token;
}
});
</script>
ここで読めるのは、htmx が送信した HTTP リクエストのレスポンスヘッダーです。
注意点として、通常のページ表示で返された HTML のレスポンスヘッダーを、あとから JavaScript で直接読むことはできません。
そのため、初回トークンもレスポンスヘッダーで取得したい場合は、ページ読み込み後に専用エンドポイントを叩きます。
<div
hx-get="/csrf-token"
hx-trigger="load"
hx-swap="none">
</div>
サーバー側は、たとえば次のように返します。
HTTP/1.1 204 No Content
X-CSRF-Token: initial-token
hx-swap="none" にしておけば、画面は更新せず、レスポンスヘッダーだけを受け取れます。
5. レスポンスヘッダー由来:htmx:configRequest で送る
受け取った CSRF トークンは、次回以降の htmx リクエストに付けます。
<script>
let csrfToken = null;
document.body.addEventListener("htmx:afterRequest", (event) => {
const token = event.detail.xhr.getResponseHeader("X-CSRF-Token");
if (token) {
csrfToken = token;
}
});
document.body.addEventListener("htmx:configRequest", (event) => {
if (csrfToken) {
event.detail.headers["X-CSRF-Token"] = csrfToken;
}
});
</script>
これで、hx-post や hx-delete のリクエストに CSRF トークンを自動で付けられます。
6. GET には付けない形にする
CSRF トークンは、通常は状態変更リクエストに必要です。
そのため、GET ではなく、POST、PUT、PATCH、DELETE のときだけ付けるようにすると意図が明確になります。
<script>
let csrfToken = null;
const unsafeMethods = new Set(["post", "put", "patch", "delete"]);
document.body.addEventListener("htmx:afterRequest", (event) => {
const token = event.detail.xhr.getResponseHeader("X-CSRF-Token");
if (token) {
csrfToken = token;
}
});
document.body.addEventListener("htmx:configRequest", (event) => {
const method = event.detail.verb?.toLowerCase();
if (csrfToken && unsafeMethods.has(method)) {
event.detail.headers["X-CSRF-Token"] = csrfToken;
}
});
</script>
この形にしておくと、単なる表示更新の hx-get と、状態変更の hx-post / hx-delete を分けて考えやすくなります。
7. 初回トークン取得前の送信を防ぐ
レスポンスヘッダー由来にする場合、初回トークンを取得する前に hx-post されると困ります。
そのため、状態変更ボタンは最初は無効化しておきます。
<div
hx-get="/csrf-token"
hx-trigger="load"
hx-swap="none">
</div>
<button
data-requires-csrf
disabled
hx-post="/settings"
hx-target="#result">
保存
</button>
<div id="result"></div>
<script>
let csrfToken = null;
const unsafeMethods = new Set(["post", "put", "patch", "delete"]);
document.body.addEventListener("htmx:afterRequest", (event) => {
const token = event.detail.xhr.getResponseHeader("X-CSRF-Token");
if (!token) return;
csrfToken = token;
document.querySelectorAll("[data-requires-csrf]").forEach((el) => {
el.disabled = false;
});
});
document.body.addEventListener("htmx:configRequest", (event) => {
const method = event.detail.verb?.toLowerCase();
if (csrfToken && unsafeMethods.has(method)) {
event.detail.headers["X-CSRF-Token"] = csrfToken;
}
});
</script>
これが、レスポンスヘッダー由来の CSRF トークンを扱うシンプルな構成です。
8. 使い分け
シンプルにまとめると、次のようになります。
| 状況 | おすすめ |
|---|---|
| 通常フォーム中心 | hidden input |
フォーム外の hx-post / hx-delete が多い |
meta + htmx:configRequest
|
| サーバーがレスポンスヘッダーでトークンを返す |
htmx:afterRequest + htmx:configRequest
|
| 初回トークンもヘッダーで取得したい |
/csrf-token を hx-trigger="load" で取得 |
| JavaScript をなるべく書きたくない | hidden input |
| CSRF 処理をエンジニア管理に寄せたい | 共通 JavaScript でヘッダー付与 |
まとめ
CSRF トークンは、どこに置くかで扱い方が変わります。
hidden input:
HTMLフォームに近い
フォーム中心なら単純
ただし、フォームごとに配置が必要
meta:
HTMLに初回トークンを置きつつ、JavaScriptで送信制御できる
フォーム外の htmx 操作に向いている
レスポンスヘッダー:
JavaScriptで受け取り、JavaScriptで送る
エンジニア責務として切り出しやすい
トークン更新を共通処理にしやすい
htmx は HTML から HTTP リクエストを送れる便利な道具です。
その一方で、hx-post や hx-delete を使うと、HTML が単なる表示ではなく、状態変更リクエストを発行する入口になります。
そのため、CSRF トークンの扱いは「hidden input を置くかどうか」だけではなく、次のように考えると整理しやすくなります。
フォームに閉じた処理:
hidden input
フォーム外の操作:
meta + htmx:configRequest
レスポンスヘッダーでトークンを更新する処理:
htmx:afterRequest + htmx:configRequest
フォーム中心なら hidden input。
操作 UI が増えるなら htmx:configRequest。
レスポンスヘッダーでトークンを扱うなら htmx:afterRequest と組み合わせる。
この3段階で考えると、htmx での CSRF トークンの扱いを整理しやすくなります。