URLエンコード・デコード実践ガイド:パーセントエンコーディングの仕組みと落とし穴
サーバーログを追っていたら、クエリ文字列に %E4%BD%A0%E5%A5%BD という謎の文字列。データ破損?バグ?いや、どちらでもない。中国語の「你好」をUTF-8で各3バイトに変換し、パーセントエンコードしたものだ。Web開発をやっていれば必ずこの壁にぶつかる。見た目は壊れているようでも、URLは設計通りに動いている。
URLエンコード(正式名称:パーセントエンコーディング)は、特殊文字をURLで安全に扱うための変換ルールだ。バイトレベルの動作原理、encodeURI と encodeURIComponent の違い、4言語での正しい書き方、ベテランでもハマるバグ――順に見ていく。
手元で試しながら読みたければ、URLデコーダー&エンコーダー にURLを貼り付けるだけでいい。
URLエンコード(パーセントエンコーディング)とは
URLに使える文字はASCIIのごく一部だけだ。英字、数字、少数の記号はそのまま転送できるが、スペース、アンパサンド、中国語、絵文字はすべて変換しなければURLとして機能しない。
パーセントエンコーディングでは、安全でない各バイトを % + 2桁の16進数に置き換える。スペースなら %20、アンパサンドなら %26。名前の由来はこの % だ。
ルールの出典は RFC 3986。2005年公開で今も現役の標準規格だ。旧RFC 2396を置き換え、安全な文字・予約文字・非ASCII文字の扱いを厳密に定義し直した。
具体例:
| 入力 | エンコード結果 | 理由 |
|---|---|---|
hello world | hello%20world | スペースはURLに使用不可 |
price=10&tax=2 | price%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バイト | エンコード結果 | サイズ倍率 |
|---|---|---|---|---|
A | U+0041 | 41 | A(エンコードなし) | 1× |
| スペース | U+0020 | 20 | %20 | 3× |
é | U+00E9 | C3 A9 | %C3%A9 | 6× |
中 | U+4E2D | E4 B8 AD | %E4%B8%AD | 9× |
🚀 | U+1F680 | F0 9F 9A 80 | %F0%9F%9A%80 | 12× |
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.pdf が my+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構文) | %20 | URLのすべての部分 |
application/x-www-form-urlencoded | + | HTMLフォーム送信によるクエリ文字列 |
+ の慣習はWebの黎明期まで遡る。ブラウザが <form method="GET"> を送信するとき、クエリ文字列のスペースを + にする。HTML仕様に組み込まれた動作なので、今後もなくならない。
厄介なのは、+ がスペースを意味するのはクエリ文字列の中だけという点だ。パスセグメントでは + はリテラルのプラス記号。https://example.com/my+file.pdf は my 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バイト |
| IIS | 16,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のリクエストボディに逃がす。