URL の `#` フラグメントの落とし穴:サーバに送られない仕様、SPA・OAuth・スクロール復元の挙動

約9分

URL の # 以降(フラグメント)はサーバに送信されません。これは RFC 3986 §3.5 で定義された仕様ですが、ここから派生する挙動を知らないと「サーバ側ログにフラグメントが出てこない」「SPA のルーティングが想定通り動かない」「OAuth implicit flow でアクセストークンが漏れる」といった事故が起きます。本記事では # フラグメント周りの具体的な落とし穴を整理します。

基礎:# 以降はクライアントだけが知っている

https://example.com/page?q=hello#section-2
└─────────────────────────────────┘└────────┘
       サーバに送信される           クライアントだけ

ブラウザが HTTP リクエストを送るとき、URL の ? までは Host ヘッダ + パス + クエリ文字列として送信されますが、# 以降はネットワークに乗りません。これは:

  • ナビゲーション完了後、ブラウザがページ内の anchor にスクロールするための情報
  • ページ全体を再読み込みせずに JavaScript が状態を持つための場所

影響:サーバ側ログにフラグメントが残らない

「ユーザーがどの # 付き URL でアクセスしたかを Apache のアクセスログで調査したい」は不可能です。サーバはフラグメントを見たことがありません。クライアント側で計測する必要があります(GA / RUM)。

落とし穴 1:OAuth implicit flow のアクセストークン漏洩

OAuth 2.0 の implicit flow(廃止勧告済みだが現存実装あり)では、認可サーバがアクセストークンを # フラグメントとして返します:

https://app.example.com/callback#access_token=eyJhbGc...&token_type=Bearer

これは「フラグメントはサーバに送られない=OAuth サーバのサーバログに残らない」という仕様意図でした。しかし:

  • クライアントがインストール済みブラウザ拡張がフラグメントを読める
  • ブラウザの履歴(History API)に記録される
  • Referer ヘッダの扱い:古いブラウザは Referer に # 以降を含めることがあり、外部リソース(広告タグなど)にトークンが漏れる可能性

このため OAuth 2.1 では implicit flow が deprecated 扱いです。代わりに Authorization Code + PKCE を使い、トークンは server-to-server で取得する設計に変わっています。

落とし穴 2:SPA の hash-based routing

「ハッシュバング (#!)」「hash routing」は SPA の伝統的なルーティング手法:

https://app.example.com/#/dashboard
https://app.example.com/#!/users/123

利点:

  • 古いサーバ設定(任意のパスを index.html にフォールバック)が不要
  • 全 URL が同じファイルを返す静的ホスティングでも動く

欠点:

  • 検索エンジンが認識しにくい(Google は #! ハッシュバングのクロール仕様を 2015 年に廃止)
  • フラグメントなのでサーバに分析データが届かない
  • 共有時に # が文字エスケープ問題を起こす

推奨:History API ベースの routing へ移行

現代の SPA は pushState / popstate を使った routing が標準(React Router、Vue Router、SvelteKit はすべてデフォルトでこちら):

https://app.example.com/dashboard
https://app.example.com/users/123

サーバ側で「全パスを index.html にフォールバック」する設定が必要ですが、Cloudflare Pages・Netlify・Vercel・SvelteKit adapter-static には標準オプションとして用意されています。

落とし穴 3:スクロール位置復元と #

ブラウザは #section-2 のような fragment があれば、その ID を持つ要素までスクロールします。これは古典的な anchor jump です。

:target 擬似クラス

CSS で :target を使うと、フラグメントが指している要素にスタイルを当てることができます:

:target {
	background: yellow;
	scroll-margin-top: 80px; /* sticky header の分を引く */
}

scroll-margin-top は固定ヘッダ + anchor jump の組み合わせで便利。#section-2 に飛んだとき、要素がヘッダの下に隠れない位置までスクロールしてくれます。

History API と組み合わせたとき

history.pushState() で URL を変更しても、ブラウザは自動でスクロールしません# を変える専用操作(location.hash = "section-2")だけが anchor jump を起こします:

location.hash = 'section-2'; // スクロールする
history.pushState(null, '', '#section-2'); // スクロールしない

SPA で「URL に # を入れたいがスクロールはしたくない」場合は pushState 系を使い、自前で scrollIntoView する設計が一般的。

落とし穴 4:Cookie の Path に # は含まれない

Cookie の Path 属性はサーバ側の仕様なので、フラグメントを参照できません

Set-Cookie: foo=bar; Path=/dashboard

このクッキーは /dashboard 配下のすべてのリクエストに送信されます。/dashboard#tab=settings のような URL でも #tab=settings は無視され、「/dashboard にマッチする」と判定されます(フラグメントはそもそもサーバに送られない)。

# ごとに別の cookie を出し分けたい」は不可能です。クエリパラメータか別のパスにする必要があります。

落とし穴 5:URL エンコードが必要な文字

フラグメント内に使える文字は ASCII の pchar / "/" / "?"RFC 3986 §3.5)に限られます。日本語や特殊文字を含めるなら percent-encoding が必要:

https://example.com/page#日本語        ← 厳密には NG
https://example.com/page#%E6%97%A5%E6%9C%AC%E8%AA%9E   ← OK

実用上はブラウザが自動で encode してくれるケースが多いですが、サーバから生成する URLメールのリンク ではエンコードが必要。encodeURIComponent() を通すのが基本:

const url = `/page#${encodeURIComponent(value)}`;

落とし穴 6:HTTP リダイレクト時のフラグメント保持

HTTP/1.1 302 FoundLocation ヘッダにリダイレクト先を指定する場合、ブラウザの挙動はバラバラです:

  • リダイレクト先に #ない場合:元の URL のフラグメントを引き継ぐ(多くのブラウザ)
  • リダイレクト先に #ある場合:それで上書き

これにより「/login#section-2 から /dashboard へリダイレクトしたら #section-2 が引き継がれた」のような予期しない挙動が起きる場合があります。

仕様

RFC 7231 §7.1.2 は「Location にフラグメントが無い場合、ユーザーエージェントは元のフラグメントを引き継ぐべき (SHOULD)」と規定。明示的に消したい場合は、リダイレクト先に空のフラグメント https://example.com/dashboard# を含めることで多くのブラウザで打ち消せます。

まとめ

URL フラグメントは「クライアントだけが知っている、サーバは見えない情報」という基本仕様から、ログに残らない・OAuth で漏れる・SPA routing の選択肢になる・cookie path に影響しない・redirect で意外に引き継がれる、といった派生挙動が出てきます。設計時に「この情報はサーバ側で必要か」を最初に判定すれば、フラグメントを使うかクエリパラメータにするかが自然に決まります。

URL の percent-encoding 周りの挙動は URL エンコードツール で確認できます。