はじめに
自己ホスト型の請求書管理 OSS NeNe Invoice(PHP + SPA)を共有レンタルサーバー(HETEML)にデプロイしたところ、奇妙な壊れ方をしました。
- ログイン(cookie ベースの silent refresh)は 成功する
- なのに直後の
GET /admin/meがmissing_tokenで 401 - SPA は「セッションが切れました」表示に落ちる
ローカルの Docker では一度も再現しない。本番だけで起きる。原因は、前段のプロキシが Authorization ヘッダだけを握りつぶしていたことでした。
この記事は、その切り分け手順と、.htaccess の定番対策では救えなかった環境で最終的に採った「カスタムヘッダへのミラー送信」の実装をまとめたものです。同じ症状(cookie 認証は通るのに Bearer だけ 401)でハマっている人の時間を節約できれば幸いです。
前提: 認証の構成
NeNe Invoice の管理 SPA は、よくある二段構えです。
-
refresh token —
HttpOnlycookie。POST /auth/refreshにだけ送られる -
access token — メモリ上に保持し、API リクエストの
Authorization: Bearer ...で送る
「ログインは通るのに API が 401」という症状は、この構成だと綺麗に説明がつきます。cookie は届いているが、Authorization ヘッダが届いていない。輸送経路が違うので、片方だけ死ねます。
切り分け: 消しているのは誰か
Authorization が PHP に届かない原因は、手前から順に3層あります。
どの層で消えているかは、ヘッダをエコーする使い捨てエンドポイントを置くと一発で分かります。
<?php // probe.php — 調査が終わったら必ず消す
header('Content-Type: application/json');
echo json_encode([
'HTTP_AUTHORIZATION' => $_SERVER['HTTP_AUTHORIZATION'] ?? '(なし)',
'HTTP_X_CUSTOM_PROBE' => $_SERVER['HTTP_X_CUSTOM_PROBE'] ?? '(なし)',
]);
$ curl -s https://example.jp/probe.php \
-H 'Authorization: Bearer test123' \
-H 'X-Custom-Probe: hello'
{"HTTP_AUTHORIZATION":"(なし)","HTTP_X_CUSTOM_PROBE":"hello"}
HETEML での実測がこれでした。カスタムヘッダは素通しなのに、Authorization だけが消える。 これが重要な手がかりで、Apache の CGI 連携が原因(レイヤ2)なら後述の .htaccess で直りますが、カスタムヘッダが届いているのに標準ヘッダだけ消えるのは、より手前の設備(レイヤ1)が意図的に落としている signature です。
対策は三段構え
第一段: .htaccess の定番(レイヤ2向け)
CGI/FastCGI 構成の Apache は、仕様上 Authorization を環境変数に渡しません。これが原因の環境では定番の一行で直ります。
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
Apache 2.4.13+ で設定を触れるなら CGIPassAuth On でも同じことができます。まずこれを試すべきで、多くの共有ホストはここで解決します。NeNe Invoice もこの行を .htaccess に入れています — 効く環境のために。
第二段: それでも消える環境がある(レイヤ1)
HETEML では第一段が効きませんでした。%{HTTP:Authorization} の時点で空 — つまり Apache に届く前に消えている。ホスティング事業者の前段プロキシが落としているので、ユーザー側の設定では取り返せません。
推測ですが、共有ホストの前段が Authorization を落とすのは、事業者側の Basic 認証設備との干渉を避けるためと思われます。いずれにせよ、こちらから制御できない層です。
第三段: カスタムヘッダへのミラー送信
残る手は「消されないヘッダで運ぶ」です。SPA 側は Bearer トークンを標準ヘッダと X-Authorization の両方に載せます。
function attachBearer(headers: Record<string, string>): void {
if (authToken === null) return
headers['Authorization'] = `Bearer ${authToken}`
headers['X-Authorization'] = `Bearer ${authToken}`
}
バックエンドは PSR-7 request の生成直後に、Authorization が不在のときだけミラーを採用します。
final class AuthorizationHeaderFallback
{
public const string FALLBACK_HEADER = 'X-Authorization';
public static function apply(ServerRequestInterface $request): ServerRequestInterface
{
if ($request->getHeaderLine('Authorization') !== '') {
return $request; // 標準ヘッダが届く環境には何もしない
}
$fallback = $request->getHeaderLine(self::FALLBACK_HEADER);
if ($fallback === '') {
return $request;
}
return $request->withHeader('Authorization', $fallback);
}
}
この設計で意識した点が3つあります。
1. 標準ヘッダが常に優先。 Authorization が届く環境では fallback は一切動きません。挙動が変わるのは「壊れていた環境」だけです。
2. 配線は入口の1箇所だけ。 index.php の request 生成直後に apply() を挟むので、認証ミドルウェアもハンドラも Authorization を読むコードのままです。「X-Authorization も見る」という知識がコードベースに漏れません。
$request = $creator->fromGlobals();
$request = AuthorizationHeaderFallback::apply($request);
3. トークンの意味は変えない。 同じ Bearer トークンを別名で運んでいるだけで、有効期限も検証ロジックも同一です。
注意点: キャッシュとの相性
ひとつ罠があります。HTTP のキャッシュ仕様では「Authorization つきリクエストへのレスポンスは共有キャッシュに保存しない」という保護が効きますが、X-Authorization は誰もそんな約束をしてくれません。経路上に共有キャッシュ(CDN・リバースプロキシ)がいる構成では、認証付きレスポンスが Cache-Control: private / no-store を明示していることを確認してください。API が最初から no-store を返しているなら(NeNe Invoice はそうしています)、この心配はありません。
また、クロスオリジンで使う場合はカスタムヘッダなので CORS の preflight 対象になります。same-origin の SPA なら関係ありません。
まとめ
| 症状 | 原因の層 | 対策 |
|---|---|---|
$_SERVER に HTTP_AUTHORIZATION がない(CGI/FastCGI) |
Apache |
.htaccess の E=HTTP_AUTHORIZATION / CGIPassAuth On
|
カスタムヘッダは届くのに Authorization だけ消える |
前段プロキシ | ユーザー側では救えない → ミラーヘッダ fallback |
| cookie 認証は通るのに Bearer だけ 401 | 上のどちらか | まず probe で層を特定する |
「ログインは通るのに直後の API が 401」は、認証ロジックのバグに見えて、実はヘッダの輸送事故であることがあります。コードを疑う前に、curl とエコーエンドポイントで「何がどこまで届いているか」を測るのが最短でした。
NeNe Invoice は MIT ライセンスの OSS です。この記事の実装は GitHub の src/Http/AuthorizationHeaderFallback.php(テスト込み)にあります。
── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp