CreaTools LogoCreaTools
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箇所に決める

関連記事