URL Encode
URLの二重エンコードで起きる事故と防ぎ方
2025-11-29
結論から
二重エンコードは「すでにエンコードされた値を、もう一度 encodeURIComponent している」だけ。
%25を見つけたら二重エンコード。原因は「誰がエンコードするか」が決まっていないこと。
正常: %E6%9D%B1
二重: %25E6%259D%25B1 ← %が%25になっている
即決フロー:二重エンコードの見分け方
┌─────────────────────────────────────────┐
│ URLに %25 が含まれている? │
└─────────────────────────────────────────┘
↓ Yes
┌─────────────────────────────────────────┐
│ ✓ 二重エンコードを疑う │
│ 1回デコードして %XX が残るか確認 │
└─────────────────────────────────────────┘
↓ %XXが残る
┌─────────────────────────────────────────┐
│ ✓ 二重エンコード確定 │
│ もう1回デコードで元に戻る │
└─────────────────────────────────────────┘
この記事が解決する状況
| あなたの状況 | 読むべきセクション |
|---|---|
APIレスポンスに %25 が含まれている | 二重エンコードとは |
| デコードしても日本語に戻らない | 見分け方と修正方法 |
| どこで二重エンコードされたかわからない | 原因の特定方法 |
| 二度と同じ事故を起こしたくない | 設計で防ぐ |
二重エンコードとは
%E6%9D%B1(「東」のエンコード結果)を再度エンコードすると、%25E6%259D%25B1 になる。
元の文字: 東
1回目: %E6%9D%B1
2回目: %25E6%259D%25B1
% が %25 に変換されるため。
何が問題か
サーバー側でデコードしても %E6%9D%B1 に戻るだけ。「東」には戻らない。
送信: %25E6%259D%25B1
1回デコード: %E6%9D%B1 ← まだエンコード状態
2回デコード: 東 ← やっと戻る
見分け方
%25 を探す
| 状態 | 例 | 見分けポイント |
|---|---|---|
| 正常 | %E6%9D%B1 | % の後に2桁の16進数 |
| 二重 | %25E6%259D%25B1 | %25 が繰り返し出現 |
確認コード
const suspicious = "%25E6%259D%25B1";
// 1回デコード
const once = decodeURIComponent(suspicious);
console.log(once); // → %E6%9D%B1
// まだ%が含まれている → 二重エンコードされていた
if (once.includes('%')) {
console.log("二重エンコードです");
console.log(decodeURIComponent(once)); // → 東
}
原因の特定方法
パターン1: エンコード済みかどうか確認せずに処理
// 既にエンコード済み
const url = "https://example.com?q=%E6%9D%B1%E4%BA%AC";
// さらにエンコード → 壊れる
const broken = encodeURIComponent(url);
入力が「生の文字列」なのか「エンコード済み」なのかを確認していない。
パターン2: 複数の処理が重複してエンコード
フロントエンド: encodeURIComponent(value)
バックエンド: もう一度 encodeURIComponent(value)
結果: 二重エンコード
パターン3: コピペの繰り返し
1. ブラウザのアドレスバーからコピー(既にエンコード済み)
2. ツールに貼り付けてエンコード
3. 結果を別のツールに貼り付けてさらにエンコード
4. 三重エンコードの完成
修正方法
正常になるまでデコード
function decodeUntilStable(str) {
let prev = str;
let current = decodeURIComponent(str);
while (current !== prev) {
prev = current;
try {
current = decodeURIComponent(prev);
} catch {
break;
}
}
return current;
}
decodeUntilStable("%25E6%259D%25B1"); // → 東
設計で防ぐ
二重エンコードは「誰がエンコードするか」が決まっていないと起きる。
原則1: エンコードの責任者を1箇所に決める
✓ 正しい設計
フロントエンド: エンコードして送信
バックエンド: そのまま受け取り、デコードして使用
✗ 悪い設計
フロントエンド: エンコードして送信
バックエンド: 念のためもう一度エンコード ← ここで壊れる
原則2: 入力境界を明確にする
| 入力元 | 状態 | 処理 |
|---|---|---|
| ユーザー入力 | 未エンコード | エンコードする |
| location.href | エンコード済み | エンコードしない |
| APIレスポンス | 仕様による | 確認してから判断 |
原則3: ドキュメント化する
【API仕様】
リクエスト: クエリパラメータはエンコード済みで送信すること
レスポンス: URLはエンコード済みで返却される
事故事例
事例1: リダイレクトURLが開けない
状況: OAuth認証のコールバックURLがエラー
原因: redirect_uri を二重エンコードして送信
調査: URLに %253A%252F%252F が含まれていた
対処: エンコード処理を1回に修正
事例2: 検索結果が0件
状況: 日本語検索で結果が返らない
原因: フレームワークが自動エンコード → 自前でもエンコード
調査: サーバーログで %25E6%259D%25B1 を発見
対処: 自前のエンコード処理を削除
事例3: Webhook登録失敗
状況: Webhook URLの登録でバリデーションエラー
原因: 既にエンコード済みのURLを再エンコード
対処: 入力値がエンコード済みかどうかのチェックを追加
デバッグのコツ
1. 処理フローを図示する
ユーザー入力 → フロントJS → サーバーAPI → データベース
↑ ↑
ここでエンコード? ここでもエンコード?
2. 各ポイントでログを出す
console.log("入力:", value);
console.log("エンコード後:", encodeURIComponent(value));
// サーバー側でも受信値をログ出力
3. 段階的にデコードして確認
let v = "%25E6%259D%25B1";
while (v.includes('%')) {
console.log(v);
try { v = decodeURIComponent(v); } catch { break; }
}
まとめ
%25を見たら二重エンコード。「誰がエンコードするか」を設計時に決めれば、二度と起きない。
| チェック項目 | 対処 |
|---|---|
%25 が含まれている | 二重エンコードを疑う |
1回デコードしても % が残る | もう1回デコード |
| どこで二重になったかわからない | 処理フローを図示 |
| 再発防止 | エンコードの責任者を1箇所に決める |
関連記事
- URLエンコード完全ガイド — encodeURI と encodeURIComponent の基本