Skip to content
ブログに戻る
チュートリアル

URLエンコード・デコード実践ガイド:パーセントエンコーディングの仕組みと落とし穴

RFC 3986のルール、encodeURIとencodeURIComponentの違い、UTF-8バイトマッピング、JS/Python/Go/Javaのコード例つき。オンラインツールで試しながら読める。

12分で読める

URLエンコード・デコード実践ガイド:パーセントエンコーディングの仕組みと落とし穴

サーバーログを追っていたら、クエリ文字列に %E4%BD%A0%E5%A5%BD という謎の文字列。データ破損?バグ?いや、どちらでもない。中国語の「你好」をUTF-8で各3バイトに変換し、パーセントエンコードしたものだ。Web開発をやっていれば必ずこの壁にぶつかる。見た目は壊れているようでも、URLは設計通りに動いている。

URLエンコード(正式名称:パーセントエンコーディング)は、特殊文字をURLで安全に扱うための変換ルールだ。バイトレベルの動作原理、encodeURIencodeURIComponent の違い、4言語での正しい書き方、ベテランでもハマるバグ――順に見ていく。

手元で試しながら読みたければ、URLデコーダー&エンコーダー にURLを貼り付けるだけでいい。

URLエンコード(パーセントエンコーディング)とは

URLに使える文字はASCIIのごく一部だけだ。英字、数字、少数の記号はそのまま転送できるが、スペース、アンパサンド、中国語、絵文字はすべて変換しなければURLとして機能しない。

パーセントエンコーディングでは、安全でない各バイトを % + 2桁の16進数に置き換える。スペースなら %20、アンパサンドなら %26。名前の由来はこの % だ。

ルールの出典は RFC 3986。2005年公開で今も現役の標準規格だ。旧RFC 2396を置き換え、安全な文字・予約文字・非ASCII文字の扱いを厳密に定義し直した。

具体例:

入力エンコード結果理由
hello worldhello%20worldスペースはURLに使用不可
price=10&tax=2price%3D10%26tax%3D2=& は構造上の意味を持つ
%E4%B8%AD非ASCII → UTF-8バイト → パーセントエンコード
🚀%F0%9F%9A%80絵文字 → 4バイトUTF-8 → パーセントエンコード

エンコードが必要な文字

RFC 3986は文字を3グループに分けている。デバッグ時にこの分類が頭に入っているかどうかで速度が変わる。

非予約文字(エンコード不要)

この66文字だけが、URLのどこでもそのまま使える:

A-Z  a-z  0-9  -  .  _  ~

英字、数字、ハイフン、ピリオド、アンダースコア、チルダ。これだけだ。それ以外にフリーパスはない。

予約文字(文脈依存)

URLの構造的な区切りに使われる文字群:

文字URLでの役割
:スキームと権限部の区切り(https:
/パスセグメントの区切り
?クエリ文字列の開始
#フラグメントの開始
&クエリパラメータの区切り
=パラメータのキーと値の区切り
@ユーザー情報とホストの区切り
+ ! $ ' ( ) * , ; [ ]その他の予約用途

原則はシンプルで、予約文字が構造上の役割を果たしているならそのまま。データの一部として現れるなら(パラメータ値の中など)エンコードする。

その他すべて(常にエンコード)

スペース、山括弧、波括弧、パイプ、バックスラッシュ、非ASCII文字(中国語、アラビア語、絵文字)――URLに入れる前に必ずパーセントエンコードする。

スペースだけは厄介で、RFC 3986では %20 だが、HTMLフォーム送信では + になる。この食い違いは後述

URLエンコードの仕組み:UTF-8パイプライン

ASCII文字は簡単で、バイト値を16進数にして % を付けるだけ。スペース(0x20)は %20

非ASCIIテキストは少し手順が増える:

文字 → Unicodeコードポイント。 é は U+00E9、🚀 は U+1F680。

コードポイント → UTF-8バイト列。 UTF-8はコードポイントの範囲で1〜4バイトを使い分ける。é(U+00E9)なら2バイト 0xC3 0xA9、ロケット絵文字(U+1F680)なら4バイト 0xF0 0x9F 0x9A 0x80

各バイトを %XX 形式に。 各バイトに % + 16進2桁を付ける。

文字ごとの変換結果を並べるとこうなる:

文字コードポイントUTF-8バイトエンコード結果サイズ倍率
AU+004141A(エンコードなし)
スペースU+002020%20
éU+00E9C3 A9%C3%A9
U+4E2DE4 B8 AD%E4%B8%AD
🚀U+1F680F0 9F 9A 80%F0%9F%9A%8012×

JavaScriptで追ってみる:

const char = '中';
const encoded = encodeURIComponent(char);
console.log(encoded); // '%E4%B8%AD'

// Trace the bytes
const bytes = new TextEncoder().encode(char);
console.log([...bytes].map(b => '%' + b.toString(16).toUpperCase()).join(''));
// '%E4%B8%AD' — matches

このサイズ膨張が URL長制限 に効いてくる。中国語20文字でパーセントエンコード分だけで180文字増える計算だ。

encodeURI vs encodeURIComponent ― 正しい関数の選び方

URLエンコード関連で一番多いミスがこの2つの取り違えだ。名前は似ているのに、エンコード対象の文字セットがまるで違う。

encodeURI()encodeURIComponent()
用途URL全体をエンコード単一コンポーネント(パラメータのキーまたは値)をエンコード
保持する文字: / ? # & = @ + $ ,いずれもエンコード
エンコードする文字スペース、非ASCII、一部の記号A-Z a-z 0-9 - _ . ~ ! ' ( ) * 以外すべて
使う場面パスにスペースやUnicodeを含む完全なURLがある場合ユーザー入力からクエリパラメータを構築する場合

本番でよく踏むバグ:

// ❌ BUG: encodeURI does NOT encode &
const search = 'Tom & Jerry';
const bad = `https://api.example.com/search?q=${encodeURI(search)}`;
// Result: https://api.example.com/search?q=Tom%20&%20Jerry
// The & splits the query string — server sees q=Tom%20 and a separate param %20Jerry

// ✅ FIX: encodeURIComponent encodes & as %26
const good = `https://api.example.com/search?q=${encodeURIComponent(search)}`;
// Result: https://api.example.com/search?q=Tom%20%26%20Jerry

迷ったら encodeURIComponent() URL構築の95%はこちらで合っている。

両方のモードを並べて比較してみる →

各言語でのURLエンコード

JavaScript(ブラウザ&Node.js)

// Encode a parameter value
const value = encodeURIComponent('price >= 100 & currency = €');
// 'price%20%3E%3D%20100%20%26%20currency%20%3D%20%E2%82%AC'

// Decode
const original = decodeURIComponent(value);
// 'price >= 100 & currency = €'

// Modern approach: URLSearchParams handles encoding automatically
const params = new URLSearchParams({ q: 'hello world', lang: '中文' });
console.log(params.toString());
// 'q=hello+world&lang=%E4%B8%AD%E6%96%87'
// Note: URLSearchParams uses + for spaces (form encoding)

Python

from urllib.parse import quote, unquote, urlencode

# Encode a path segment
quote('hello world/file name.txt', safe='/')
# 'hello%20world/file%20name.txt'

# Encode query parameters
urlencode({'q': '你好', 'page': '1'})
# 'q=%E4%BD%A0%E5%A5%BD&page=1'

# quote_plus uses + for spaces (form encoding)
from urllib.parse import quote_plus
quote_plus('hello world')  # 'hello+world'
quote('hello world')       # 'hello%20world'

Go

import "net/url"

// Encode a query value (uses + for spaces)
url.QueryEscape("hello world & more")
// "hello+world+%26+more"

// Encode a path segment (uses %20 for spaces)
url.PathEscape("hello world & more")
// "hello%20world%20&%20more"

// Build a URL safely with url.Values
params := url.Values{}
params.Set("q", "你好世界")
params.Set("page", "1")
fmt.Println(params.Encode())
// "page=1&q=%E4%BD%A0%E5%A5%BD%E4%B8%96%E7%95%8C"

Java

import java.net.URLEncoder;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;

// Encode (uses + for spaces — Java follows form encoding)
String encoded = URLEncoder.encode("hello world & more", StandardCharsets.UTF_8);
// "hello+world+%26+more"

// For RFC 3986 compliance, replace + with %20
String rfc3986 = encoded.replace("+", "%20");
// "hello%20world%20%26%20more"

// Decode
String decoded = URLDecoder.decode(encoded, StandardCharsets.UTF_8);
// "hello world & more"

GoとJavaはデフォルトがフォームエンコーディング(スペース → +)。RFC 3986に合わせたければ、出力後に +%20 に置換する。

プロダクションを壊す5つのURLエンコードバグ

1. 二重エンコード(%20 ではなく %2520

自分でURLエンコードした文字列を、フレームワークがもう一度エンコードする。%20%%25 になり、サーバーにはスペースではなくリテラルの %20 が届く。

症状: URLに %2520%253D など %25xx パターンが出現。

見分け方: %25 があったらまず二重エンコードを疑う。% 自体がエンコードされている証拠だ。

直し方: 先にデコードしてから一度だけエンコード。エンコード済みかどうか確認せず重ねがけしない。

// Detect double encoding
function isDoubleEncoded(str) {
  return /%25[0-9A-Fa-f]{2}/.test(str);
}

// Safe encode: decode first, then encode
function safeEncode(str) {
  try { str = decodeURIComponent(str); } catch (e) { /* not encoded, that's fine */ }
  return encodeURIComponent(str);
}

2. パスセグメント内の +

ファイル名のエンコードにスペースを + に変換するライブラリを使ってしまい、my report.pdfmy+report.pdf に。サーバーは + をリテラルのプラス記号と解釈し、404が返る。

鉄則: + がスペースを意味するのはクエリ文字列(? の後)だけ。パスセグメントでは + はただの +。パス内のスペースには必ず %20 を使う。

3. OAuthリダイレクトURIの破損

認可URLがこうなっていたとする:

https://auth.provider.com/authorize?redirect_uri=https://myapp.com/callback?code=abc&state=xyz

OAuthサーバーは redirect_uri=https://myapp.com/callback?code=abc までしか認識せず、state=xyz はトップレベルの別パラメータ扱いになる。認証は通らない。

直し方: リダイレクトURI全体をエンコードする:

const redirectUri = 'https://myapp.com/callback?code=abc&state=xyz';
const authUrl = `https://auth.provider.com/authorize?redirect_uri=${encodeURIComponent(redirectUri)}`;
// redirect_uri=https%3A%2F%2Fmyapp.com%2Fcallback%3Fcode%3Dabc%26state%3Dxyz

4. ログ内の非ASCIIテキストの文字化け

サーバーログに中国語の代わりに %E4%BD%A0%E5%A5%BD が並んでいる。バグではなく、URLは正しくエンコードされている。ログビューアがパーセントエンコードを展開していないだけだ。

直し方: ログをデコーダーに通す。手っ取り早いのは URLデコーダー にそのまま貼り付けること。

5. API署名の不一致

OAuth 1.0やAWS Signature V4は厳密なRFC 3986エンコードを前提としている。ところが encodeURIComponent()!'()* をエンコードしない。署名入力にこれらが混ざると、署名が合わなくなる。

直し方: 出力を後処理する:

function rfc3986Encode(str) {
  return encodeURIComponent(str).replace(/[!'()*]/g, c =>
    '%' + c.charCodeAt(0).toString(16).toUpperCase()
  );
}

%20 vs + ― スペースエンコーディングのジレンマ

2つの標準、1つの文字、終わりなき混乱。

標準スペースの変換先適用範囲
RFC 3986(URI構文)%20URLのすべての部分
application/x-www-form-urlencoded+HTMLフォーム送信によるクエリ文字列

+ の慣習はWebの黎明期まで遡る。ブラウザが <form method="GET"> を送信するとき、クエリ文字列のスペースを + にする。HTML仕様に組み込まれた動作なので、今後もなくならない。

厄介なのは、+ がスペースを意味するのはクエリ文字列の中だけという点だ。パスセグメントでは + はリテラルのプラス記号。https://example.com/my+file.pdfmy file.pdf ではなく my+file.pdf を返す。

実践的な使い分け:

  • URL手組みやパスセグメントには %20。これならどこでも正しく動く。
  • フォーム送信由来のクエリ文字列をパースするときは + を受け入れる。大抵はフレームワークが処理済みだが。
  • 混在させない。コンポーネントごとにどちらかに統一する。

URLエンコードとセキュリティ

URLエンコードは暗号化ではない

パーセントエンコーディングは可逆な決定的変換だ。鍵も秘密もない。%48%65%6C%6C%6F は誰でもすぐ Hello に戻せる。

機密データの隠蔽には役立たない。通信の暗号化にはHTTPS。URLはサーバーログ、ブラウザ履歴、Referer ヘッダーに残るから、機密情報はリクエストボディに入れる。

オープンリダイレクト攻撃

攻撃者は、単純なバリデーションをすり抜けるエンコード済みURLを仕込む。%2F%2Fevil.com はデコードすると //evil.com で、ブラウザはこれをプロトコル相対URLとして攻撃者のドメインに飛ばす。

対策: バリデーションはエンコード済みの形式ではなくデコード後のURLに対して行う。リダイレクト先ドメインはアローリストで制限する。

二重エンコード攻撃

WAFが受信URLの <script> タグを検出しようとしている。攻撃者は %253Cscript%253E を送る。WAFにはパーセントエンコード文字列にしか見えずスルーされる。アプリが1回デコードして %3Cscript%3E、2回目で <script> が現れ、フィルタが抜かれる。

対策: セキュリティチェックの前に入力を正規化(完全にデコード)する。デコード1回だけに頼らない。

Webセキュリティ全般については Webセキュリティの基本 も参照。

URL長の制限とエンコードのコスト

HTTP仕様にURL長の上限は定義されていない。が、スタックの各層に実質的な制限がある。

レイヤー制限
一般的な推奨値2,000文字
Chrome、Firefox約2 MB(ただしサーバーはそれよりはるかに手前で拒否)
Apache(デフォルト)8,190バイト
Nginx(デフォルト)8,192バイト
IIS16,384バイト(クエリ文字列)
CDN、プロキシ様々。4,096〜8,192バイトが多い

パーセントエンコーディングはURLを長くする。中国語1文字が9文字(%E4%B8%AD)、絵文字なら12文字。中国語200文字をクエリに入れると、ベースURL抜きでエンコード分だけ1,800文字になる。

制限にぶつかったら: クエリパラメータをやめてリクエストボディに移し、POSTにする。検索系なら、条件をJSONボディで受けるPOSTエンドポイントが素直だ。

FAQ

URLエンコードとは何か、なぜ開発者に必要か

URLエンコード(パーセントエンコーディング)は、URLに使えない文字を %XX の16進表現に変換する仕組みだ。そのまま通せる非予約ASCII文字は66種類しかない。スペース、アンパサンド、Unicodeテキスト、大半の句読点――エンコードしなければURL構造が壊れるか、サーバーが誤解釈する。

encodeURIとencodeURIComponentの違いは何か

encodeURI():///?& など構造文字を残したままURL全体をエンコードする。encodeURIComponent()A-Z a-z 0-9 - _ . ~ ! ' ( ) * 以外すべてエンコードする。クエリパラメータの値には encodeURIComponent()encodeURI() を使うのは、完全なURLのスペースや非ASCIIだけを構造を壊さず処理したい場面くらいだ。

URLで %20+ と表示されることがあるのはなぜか

どちらもスペースを表すが、出どころが違う。%20 はRFC 3986準拠でURLのどこでも通る。+ はHTMLフォームエンコーディング由来で、クエリ文字列でしか通用しない。パスセグメントの + はリテラルのプラス記号だ。無難なのは %20+ はフォーム互換のために残っているレガシーと思っていい。

Python、JavaScript、Go、JavaでテキストをURLエンコードするには

JavaScript: encodeURIComponent('hello world')hello%20world。Python: urllib.parse.quote('hello world')hello%20world。Go: url.QueryEscape("hello world")hello+world。Java: URLEncoder.encode("hello world", UTF_8)hello+world。GoとJavaはデフォルトがフォームエンコーディング(スペース → +)なので、RFC 3986準拠にしたければ +%20 に置換する。

URLエンコードはセキュリティや暗号化に使えるか

使えない。URLエンコードは鍵なしで可逆だから、誰でもすぐ戻せる。機密性はゼロ。機密データにはHTTPS(TLS暗号化)を使う。URLはサーバーログ、ブラウザ履歴、Referer ヘッダーに残る。機密データはリクエストボディに入れる。

二重エンコードとは何か、どう修正するか

エンコード済みの文字列がもう一度エンコードされて起きる。%20%%25 になり %2520 が生まれる。サーバーにはスペースではなくリテラルの %20 が届く。直すには、まずデコードしてから一度だけエンコード。%25 + 16進2桁のパターンが二重エンコードの目印だ。

URLの最大長はどれくらいか

HTTP仕様に公式の上限はない。互換性重視なら2,000文字が安全ライン。Apacheデフォルトは8,190バイト、Nginxは8,192バイト。非ASCIIはパーセントエンコードで3〜12倍に膨らむから、国際化URLは上限に早く届く。大きいペイロードはPOSTのリクエストボディに逃がす。