◆ Sec-Fetch での判断が良さそうだったけど Safari は未対応らしい
◆ Safari に対応させるならその他の方法
◆ 作るもの次第で楽なものを使う

これまでやってた方法

これまで CSRF 対策にはトークンを使ってました
私の場合は フォームでの POST は一切しなくて fetch での送信のみです
body に混ぜるよりは header に入れるほうが楽なのでヘッダーで送信します
トークンはユーザまたは Cookie に対して乱数で発行して リクエスト時に送信されたトークンがユーザや Cookie のトークンと一致することを確認しています

ヘッダーを見るならトークンは必要なかった

この方法って過剰だったようです
fetch を使ったバックグラウンドでの通信の場合は ヘッダーで任意のデータを送信できますが フォームからではできません
標準でないヘッダーが送信されていることをチェックするだけでフォームからや直接アクセスは防げています

防ぐべきリクエストは外部のサイトからのリクエストです
自サイト内からのリクエストは防ぐ必要はありません
別オリジンからヘッダーを追加してリクエストする場合は GET/POST の前に OPTION メソッドの Preflight リクエストが発生します
このリクエストではリクエスト時に設定したヘッダーは送信されません
リクエストヘッダーをチェックしていれば この Preflight リクエストはブロックされて 本来のリクエストは届きません

サーバでは標準ではないヘッダーが送られてきていることだけチェックすれば中身は見なくても十分でした
全員が foo=bar というヘッダーを送ってそのチェックでもいいです

SameSite

トークンを考えなくてもいい時点で楽になりましたが まだクライアント側でリクエスト時にヘッダーを追加する必要があります
共通して呼び出す api 関数などに書いておけば別に気にするほどでもないのですが 送らずに済ませられるならそうしたいです

他の方法では Cookie の SameSite を使う方法もあります
別のサイトへのリクエストなら Cookie を送信せず ブラウザへの保存もしなくできるものです
投稿するとかログアウトするとかアカウントを削除するとかのリクエストを勝手に送られても Cookie がなくてログイン状態とみなされなければ大抵の処理は実行できず無意味に終わります
ログインのリクエストのようなログイン状態が必要ない処理は行われますが ブラウザがレスポンスにある Set-Cookie ヘッダーを無視するのでそのブラウザではログインできていないことになります

ですがこれってログイン前提のシステムじゃなければ意味がないと思います
掲示板みたいなログイン不要なところへの書き込みであれば Cookie が送られてなくても書き込みができてしまいます
これを対策するなら前述のような方法が必要になります

Origin

最近だと Origin を見る方法を使ってるところも見かけます
ブラウザが自動で送るヘッダーで どのオリジンからリクエストされたかが含まれています
JavaScript での書き換えはできないので偽装はできません
自動で送られるのでクライアント側での追加処理は不要で サーバ側で送られてきた Origin ヘッダーが自分のオリジンに一致するかをチェックします

便利そうなのですが サーバが自分のオリジンを知る必要があります
サーバが自身のオリジンを知らないことって普通にあると思います
それにオリジンが 1 つとは限らないのでユーザに合わせるために オリジン付きで URL を生成するときにはユーザが送ってきた情報をもとに作ったりします

リバースプロキシを経由するならユーザがアクセスするオリジンはリバースプロキシのものです
複数のリバースプロキシがあってユーザによってアクセスしているオリジンが変わる可能性もあります
サーバ側で管理してるなら設定で対処できますが ユーザ側でのプロキシやポートフォワードや hosts ファイル設定によって サーバ側で意図したオリジンとは異なるオリジンでアクセスしてる可能性もありえます

あと POST でしか送信されません
GET でサーバ側のステータスを変更するようなことをは避けるべきだとは言っても そういうケースが無いとはいえません
必要なったときに全体のやり方自体を変えないといけなくなるのは採用しづらい気持ちもあります

Fetch Metadata Headers

Sec-Fetch

いつの頃からかブラウザからのリクエストに Sec-Fetch-* 系のヘッダーがついています
Sec-* 系は Origin みたいにブラウザが自動で送るもので JavaScript で変更できません
Forbidden header name という名前がついてます

Sec-Fetch-* 以外に UA 代わりの Client Hints が Sec-CH-UA で送られてたりします
Sec-Fetch-* には

Sec-Fetch-Dest
Sec-Fetch-Mode
Sec-Fetch-Site
Sec-Fetch-User

があって リクエストに関する情報が入っています

Dest ではどういうコンテキストでのリクエストかがわかります
document や image や script などです

Mode には navitate や no-cors や cors などが入ります
img や script タグでのリクエストなら no-cors ですが type="module" の script や fetch でのリクエストなど CORS の制限を受けるリクエストは cors になります

Site はリクエスト元がどういう関係のところかを表します
値の種類は cross-site, same-origin, same-site, none で見たままです
ユーザが URL を直接入力したりブラウザ外から開いたりすると none になります

User はユーザの操作でリクエストされたものかを表す boolean 型です
boolean 型は HTTP ヘッダーの場合は ?0 か ?1 で表すようです
ただの 0/1 だと数値型と区別できないからでしょうか
このヘッダーの場合は ?0 になるならヘッダーが送信されないみたいです

CSRF 対策に

Sec-Fetch-Site を使えば Origin の問題点を解決して 別サイトからのリクエストをブロックできます
same-origin と same-site で分かれているので 完全に同じオリジンからのみしか受け付けないことも サブドメインやスキームやポートが違うけど同じサイトなら許可するということもできます

自分でヘッダーを追加する方法だと 同じオリジンからでもフォームを使った POST はヘッダーを追加できないのでブロックされます
この方法だとフォームからでも fetch でも送られるので正規のリクエストはどちらの方法も使えます
fetch のみに限定したいなら Sec-Fetch-Dest が empty かどうかをチェックします

Safari

特に欠点もなく Sec-Fetch ヘッダーを使う方法で良さそうです
いまだともう IE なんて考える必要もないですし

なんて思っていたら Safari が対応してませんでした
MDN の Browser compatibility を見ると✕になってます
相変わらず足手まといなブラウザですね……

Chrome/Edge のみのページなら気にせず使っても Safari だと必要なヘッダーが送られず不正扱いになるだけのはずです
Sec-* が Forbidden header name として認識されているならですけど
もしこれもされてないなら 悪意あるページから JavaScript で same-origin と偽ってリクエストを送ってこれます

こういうときに Windows だと動作確認して見れないのが面倒なところです
Playwright には Webkit もありますが こういうところも完全に Safari 準拠なんでしょうか

とりあえず Safari 対応が必要なら Sec-Fetch は使えません

if Safari はサポートしない
⇢ Sec-Fetch ヘッダーを見る

else if Origin の不便なところが許容できる
⇢ Origin ヘッダーを見る

else if ログインが前提
⇢ Cookie の SameSite

else if fetch のみでフォームからの POST はしない
⇢ 固定のヘッダー追加

else
⇢ フォームでトークン送信

という感じでしょうか