はじめに
Spring Boot + React で小さなレビュー投稿アプリを作って勉強しています。
「レビューを送信」ボタンを実装して押したら、コンソールが真っ赤に。しかも CORS policy の文字。「またCORSか…」と直し始めたら、犯人はCORSではなく、姿の違うやつが2人いました。同じ仮面をかぶって二度だましてくる——まんまと化かされました
エラーメッセージを額面どおり受け取ると迷子になる、という記録です。
環境
- Java 21 / Spring Boot 3.5.14
- React + Vite(フロント
localhost:5173/ APIlocalhost:8080) - 認証:ログインで JWT を発行 → 各リクエストに
Authorization: Bearer <token>を付ける方式
起きたこと
レビュー欄に文字を入れて送信すると、画面は無反応。コンソールにはこれ:
Access to fetch at 'http://localhost:8080/posts/8/reviews' from origin
'http://localhost:5173' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Failed to load resource: net::ERR_FAILED
おかしいのは、同じアプリの「投稿を作成」は、同じ localhost:8080 宛てでちゃんと動いていること。CORS設定がダメなら投稿作成も落ちるはずなのに……。
ちなみにこのリクエストはサーバーには届いていて、403が返っています。ただしブラウザが「CORS的にダメ」とJS側にレスポンスを渡さないので、フロントからは本当のステータスが見えにくいのです(サーバーのログを見ると403が記録されています)。
原因①:動く双子と壊れた双子を比べる
「動く投稿作成」と「動かないレビュー送信」はやることがそっくり。なら並べて違いを探せばいい。キーワードで検索しました。
grep -n "Authorization" src/App.jsx
60: 'Authorization': 'Bearer ' + token, ← 投稿作成
98: headers: { 'Authorization': 'Bearer ' + token }, ← 削除
レビュー送信の関数がこの結果に出てこない。つまり「動く方には有り、レビュー送信だけ無い」。トークンを付け忘れていました。
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token, // ← この1行が抜けていた
},
なぜ「認証なし」が「CORSエラー」に化けるのか
レビュー投稿はログイン必須。トークン無しでPOSTすると、サーバーは拒否します。ポイントは Spring Security がコントローラより手前のフィルタで弾くこと。手前で弾かれたレスポンスには Access-Control-Allow-Origin が付かない → ブラウザは「CORS的にダメ」と判断し、本当のエラーを隠して CORSエラーとして表示します。
今回の本体は、認証情報が足りないことによる 403(設定によっては401になる場合もあります)。CORSエラーはその症状。
原因②:直したのに、まだ落ちる
Authorization を足して再送信。まだ net::ERR_FAILED。Networkタブでヘッダーを確認すると、今度はちゃんと付いている:
authorization: Bearer eyJhbGci...(中略・トークンは長いので省略)...XYZ
ヘッダーはある。じゃあ中身は?
※ここではローカル学習用のトークンで確認しています。実トークンを jwt.io などの外部サイトに貼るのは避けましょう。
JWTのペイロードをデコードすると:
{ "sub": "myuser", "iat": 1781049468, "exp": 1781053068 }
exp(有効期限)が現在時刻より前=期限切れ。このアプリのJWTは発行から1時間で失効する設定(JwtUtil で 1000 * 60 * 60 ミリ秒)。ログインからだいぶ経っていたのです。
サーバーが正常かは curl で切り分け:
# 新しくログインして取り直したトークンでPOST → 200なら、サーバーもコードも正常
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost:8080/posts/8/reviews \
-H "Authorization: Bearer <新しいトークン>" \
-H "Content-Type: application/json" \
-d '{"content":"テスト"}'
# => 200
200。バックエンドは無実で、フロントが古いトークンを握りっぱなしでした。画面が「ログイン中」のままだったのは、フロントが if (!token) で文字列の有無しか見ていないから。中身が期限切れかは、サーバーに弾かれて初めてわかります。
解決
- レビュー送信のfetchに
Authorization: Bearer ' + tokenを足す - ログインし直して新しいトークンを取り直す
結局、①Authorization ヘッダーの追加 + ②ログインし直し(新しいトークン取得)の2つで直りました。"犯人が2人" だったので、対処も2つです。
なお今回は学習用なのでログインし直しで対応しました。実務では、401/403を受け取ったらトークンを破棄してログイン画面へ戻す、といった処理が必要です(今後の課題)。
学び
- DevToolsの「CORSエラー」は、本当の原因の影のことがある(特に Spring Security + 認証付きAPI)
- 認証付きPOSTが弾かれたら、まず Network で
Authorizationの有無を見る - 「動く機能」と「動かない機能」を
grepで並べて比較すると、"無いもの"が一発で見える - 直らないときは
curlでサーバー単体を試し、フロント/バックを切り分ける
おわりに
エラーメッセージって、必ずしも本当の原因を指してくれないんだな…と痛感しました。
同じ「CORSじゃないのにCORSと言われる」で固まった人の参考になれば嬉しいです🙌