Kodowanie i dekodowanie URL: przewodnik dewelopera po percent-encoding
Przeglądasz logi serwera i widzisz w query string coś takiego: %E4%BD%A0%E5%A5%BD. Uszkodzone dane? Bug? Ani jedno, ani drugie — to chińskie znaki 你好, każdy zamieniony na trzy bajty UTF-8, a następnie zakodowany procentowo do formatu bezpiecznego dla URL. Każdy webdeveloper prędzej czy później wpada na tę ścianę: coś wygląda na zepsute, a tymczasem URL działa dokładnie tak, jak został zaprojektowany.
Kodowanie URL — formalnie nazywane percent-encoding — to mechanizm, który sprawia, że znaki specjalne są bezpieczne dla URL-i. Ten przewodnik pokazuje, jak to działa na poziomie bajtów, kiedy sięgnąć po encodeURI, a kiedy po encodeURIComponent, jak poprawnie kodować w czterech językach oraz jakie błędy potrafią zaskoczyć nawet doświadczonych deweloperów.
Wklej dowolny URL w naszym narzędziu Koder i dekoder URL, aby na bieżąco obserwować kodowanie i dekodowanie podczas lektury.
Czym jest kodowanie URL (percent-encoding)?
URL może zawierać tylko niewielki podzbiór znaków ASCII. Litery, cyfry i garstka symboli przechodzą przez internet bez problemu. Wszystko inne — spacje, ampersandy, chiński tekst, emoji — musi zostać zamienione na format, który URL-e są w stanie przenieść.
Percent-encoding zastępuje każdy niebezpieczny bajt znakiem % z dwiema cyframi szesnastkowymi. Spacja staje się %20. Ampersand staje się %26. Nazwa pochodzi właśnie od prefiksu %.
Reguły opisuje RFC 3986, opublikowany w 2005 roku i wciąż obowiązujący. Zastąpił RFC 2396 i doprecyzował, które znaki są bezpieczne, które zarezerwowane oraz jak należy traktować tekst spoza ASCII.
Krótkie przykłady:
| Wejście | Zakodowane | Dlaczego |
|---|---|---|
hello world | hello%20world | Spacja jest niedozwolona w URL-ach |
price=10&tax=2 | price%3D10%26tax%3D2 | = i & mają znaczenie strukturalne |
中 | %E4%B8%AD | Spoza ASCII → bajty UTF-8 → percent-encoded |
🚀 | %F0%9F%9A%80 | Emoji → 4 bajty UTF-8 → percent-encoded |
Które znaki wymagają kodowania?
RFC 3986 dzieli znaki na trzy grupy.
Znaki niezarezerwowane (nigdy nie są kodowane)
Te 66 znaków przechodzi w stanie nienaruszonym w każdej części URL:
A-Z a-z 0-9 - . _ ~
Litery, cyfry, łącznik, kropka, podkreślnik, tylda. Tyle, koniec listy.
Znaki zarezerwowane (zależnie od kontekstu)
Te znaki pełnią rolę strukturalnych separatorów w URL-ach:
| Znak | Rola w strukturze URL |
|---|---|
: | Oddziela schemat od authority (https:) |
/ | Oddziela segmenty ścieżki |
? | Rozpoczyna query string |
# | Rozpoczyna fragment |
& | Oddziela parametry w query string |
= | Oddziela klucz parametru od wartości |
@ | Oddziela userinfo od hosta |
+ ! $ ' ( ) * , ; [ ] | Różne role zarezerwowane |
Reguła: gdy znak zarezerwowany pełni swoją funkcję strukturalną, należy zostawić go w spokoju. Gdy występuje jako dane (np. wewnątrz wartości parametru), trzeba go zakodować.
Wszystko inne (zawsze kodowane)
Spacje, nawiasy ostre, klamry, znaki potoku, ukośniki wsteczne oraz znaki spoza ASCII (chiński, arabski, emoji) muszą zostać zakodowane procentowo.
Jedna komplikacja: RFC 3986 koduje spacje jako %20, ale wysyłka formularzy HTML używa +. Więcej o tym konflikcie w dalszej części.
Jak naprawdę działa kodowanie URL: pipeline UTF-8
Dla znaków ASCII kodowanie jest proste: wystarczy odczytać wartość bajtu w hex i poprzedzić ją znakiem %. Spacja (wartość bajtu 32, hex 20) staje się %20.
Dla tekstu spoza ASCII kodowanie składa się z trzech kroków:
Krok 1 — znak na unikodowy code point.
Znak é jest mapowany do code pointu U+00E9. Emoji 🚀 mapuje się na U+1F680.
Krok 2 — code point na bajty UTF-8.
UTF-8 używa od 1 do 4 bajtów w zależności od zakresu code pointu. é (U+00E9) zamienia się na dwa bajty: 0xC3 0xA9. Emoji rakiety (U+1F680) zamienia się na cztery bajty: 0xF0 0x9F 0x9A 0x80.
Krok 3 — każdy bajt na %XX.
Każdy bajt z kroku 2 dostaje własny tryplet w formacie percent-encoded.
Oto pełny pipeline dla różnych typów znaków:
| Znak | Code point | Bajty UTF-8 | Zakodowane | Mnożnik rozmiaru |
|---|---|---|---|---|
A | U+0041 | 41 | A (bez kodowania) | 1× |
| spacja | 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× |
Można to samodzielnie zweryfikować w JavaScripcie:
const char = '中';
const encoded = encodeURIComponent(char);
console.log(encoded); // '%E4%B8%AD'
// Prześledź bajty
const bytes = new TextEncoder().encode(char);
console.log([...bytes].map(b => '%' + b.toString(16).toUpperCase()).join(''));
// '%E4%B8%AD' — zgadza się
Ta ekspansja ma znaczenie przy limitach długości URL. URL z 20 chińskimi znakami dorzuca 180 znaków tekstu zakodowanego procentowo.
encodeURI vs encodeURIComponent — jak wybrać właściwą funkcję
Te dwie funkcje JavaScript są nieustannie mylone. Wyglądają podobnie, ale kodują zupełnie różne zestawy znaków.
encodeURI() | encodeURIComponent() | |
|---|---|---|
| Cel | Zakodować kompletny URL | Zakodować pojedynczy komponent (klucz lub wartość parametru) |
| Zachowuje | : / ? # & = @ + $ , | Żadnego z nich |
| Koduje | Spacje, znaki spoza ASCII, część interpunkcji | Wszystko poza A-Z a-z 0-9 - _ . ~ ! ' ( ) * |
| Kiedy używać | Gdy mamy pełny URL ze spacjami lub znakami Unicode w ścieżce | Gdy budujemy parametry query z danych od użytkownika |
Bug, który regularnie trafia na produkcję:
// ❌ BUG: encodeURI NIE koduje &
const search = 'Tom & Jerry';
const bad = `https://api.example.com/search?q=${encodeURI(search)}`;
// Wynik: https://api.example.com/search?q=Tom%20&%20Jerry
// Znak & rozcina query string — serwer widzi q=Tom%20 i osobny parametr %20Jerry
// ✅ POPRAWKA: encodeURIComponent koduje & jako %26
const good = `https://api.example.com/search?q=${encodeURIComponent(search)}`;
// Wynik: https://api.example.com/search?q=Tom%20%26%20Jerry
W razie wątpliwości warto wybrać encodeURIComponent(). To poprawny wybór w 95% rzeczywistych scenariuszy budowania URL.
Wypróbuj oba tryby obok siebie w naszym narzędziu Koder URL →
Kodowanie URL w każdym języku
JavaScript (przeglądarka i Node.js)
// Zakoduj wartość parametru
const value = encodeURIComponent('price >= 100 & currency = €');
// 'price%20%3E%3D%20100%20%26%20currency%20%3D%20%E2%82%AC'
// Zdekoduj
const original = decodeURIComponent(value);
// 'price >= 100 & currency = €'
// Nowoczesne podejście: URLSearchParams obsługuje kodowanie automatycznie
const params = new URLSearchParams({ q: 'hello world', lang: '中文' });
console.log(params.toString());
// 'q=hello+world&lang=%E4%B8%AD%E6%96%87'
// Uwaga: URLSearchParams używa + dla spacji (form encoding)
Python
from urllib.parse import quote, unquote, urlencode
# Zakoduj segment ścieżki
quote('hello world/file name.txt', safe='/')
# 'hello%20world/file%20name.txt'
# Zakoduj parametry query
urlencode({'q': '你好', 'page': '1'})
# 'q=%E4%BD%A0%E5%A5%BD&page=1'
# quote_plus używa + dla spacji (form encoding)
from urllib.parse import quote_plus
quote_plus('hello world') # 'hello+world'
quote('hello world') # 'hello%20world'
Go
import "net/url"
// Zakoduj wartość query (używa + dla spacji)
url.QueryEscape("hello world & more")
// "hello+world+%26+more"
// Zakoduj segment ścieżki (używa %20 dla spacji)
url.PathEscape("hello world & more")
// "hello%20world%20&%20more"
// Bezpieczne budowanie URL z 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;
// Kodowanie (używa + dla spacji — Java trzyma się form encoding)
String encoded = URLEncoder.encode("hello world & more", StandardCharsets.UTF_8);
// "hello+world+%26+more"
// Dla zgodności z RFC 3986 zamień + na %20
String rfc3986 = encoded.replace("+", "%20");
// "hello%20world%20%26%20more"
// Dekodowanie
String decoded = URLDecoder.decode(encoded, StandardCharsets.UTF_8);
// "hello world & more"
Go i Java domyślnie używają form encoding (spacje jako +). Aby uzyskać wynik zgodny z RFC 3986, należy w post-procesie zamienić + na %20.
Pięć błędów w kodowaniu URL, które psują produkcję
1. Podwójne kodowanie (%2520 zamiast %20)
Kodujesz string. Framework koduje go ponownie. Znak % w %20 staje się %25, a serwer widzi dosłowny tekst %20 zamiast spacji.
Symptom: URL-e zawierają %2520, %253D lub inne wzorce %25xx.
Diagnoza: %25 w URL oznacza, że znak % został zakodowany — zwykle wskazuje to na podwójne kodowanie.
Naprawa: najpierw zdekodować, potem zakodować raz. Warto sprawdzić, czy wejście nie jest już zakodowane, zanim zostanie zakodowane ponownie.
// Wykryj podwójne kodowanie
function isDoubleEncoded(str) {
return /%25[0-9A-Fa-f]{2}/.test(str);
}
// Bezpieczne kodowanie: najpierw dekoduj, potem zakoduj
function safeEncode(str) {
try { str = decodeURIComponent(str); } catch (e) { /* nie było zakodowane, w porządku */ }
return encodeURIComponent(str);
}
2. + w segmentach ścieżki
Deweloper koduje nazwę pliku biblioteką, która zwraca + dla spacji. Plik my report.pdf staje się my+report.pdf. Serwer traktuje + jako dosłowny plus i zwraca 404.
Reguła: + oznacza spację tylko w query string (po ?). W segmentach ścieżki + to po prostu +. Dla spacji w ścieżkach należy zawsze stosować %20.
3. Zepsute redirect URI w OAuth
URL autoryzacji wygląda tak:
https://auth.provider.com/authorize?redirect_uri=https://myapp.com/callback?code=abc&state=xyz
Serwer OAuth odczytuje redirect_uri=https://myapp.com/callback?code=abc i traktuje state=xyz jako osobny parametr najwyższego poziomu. Uwierzytelnianie nie działa.
Naprawa: zakodować całą wartość redirect 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. Zniekształcony tekst spoza ASCII w logach
Logi serwera pokazują %E4%BD%A0%E5%A5%BD zamiast czytelnych chińskich znaków. URL jest poprawnie zakodowany; po prostu twoja przeglądarka logów nie dekoduje sekwencji percent-encoded.
Naprawa: przepuścić logi przez dekoder lub wkleić URL do narzędzia Dekoder URL, aby odczytać oryginalny tekst.
5. Awarie podpisywania API
OAuth 1.0 i AWS Signature V4 wymagają ścisłego kodowania zgodnego z RFC 3986. Funkcja encodeURIComponent() w JavaScripcie nie koduje !, ', (, ) ani *. Jeśli te znaki znajdą się w danych wejściowych do podpisu, sygnatura nie będzie się zgadzać.
Naprawa: post-procesować wynik:
function rfc3986Encode(str) {
return encodeURIComponent(str).replace(/[!'()*]/g, c =>
'%' + c.charCodeAt(0).toString(16).toUpperCase()
);
}
%20 vs + — dylemat kodowania spacji
Dwa standardy nie zgadzają się co do tego, jak zakodować jeden znak.
| Standard | Spacja staje się | Gdzie obowiązuje |
|---|---|---|
| RFC 3986 (składnia URI) | %20 | Wszędzie w URL |
application/x-www-form-urlencoded | + | Query string z formularzy HTML |
Konwencja + to spuścizna po wczesnych przeglądarkach internetowych. Gdy formularz <form> jest wysyłany metodą method="GET", przeglądarka koduje spacje jako + w query string. Specyfikacja HTML kodyfikuje to zachowanie.
Problem: + oznacza „spację” tylko w query string. W segmentach ścieżki + to dosłowny plus. Dlatego https://example.com/my+file.pdf serwuje plik o nazwie my+file.pdf, a nie my file.pdf.
Wskazówka praktyczna:
- Stosować
%20przy ręcznym budowaniu URL lub kodowaniu segmentów ścieżki. Działa wszędzie. - Akceptować
+przy parsowaniu query string z formularzy — twój framework zapewne robi to już sam. - Nie mieszać konwencji. Wystarczy wybrać jedną dla danego komponentu i konsekwentnie się jej trzymać.
Kodowanie URL a bezpieczeństwo
Kodowanie URL TO NIE jest szyfrowanie
Percent-encoding to w pełni odwracalna, deterministyczna transformacja bez właściwości kryptograficznych. Każdy może w milisekundach zdekodować %48%65%6C%6C%6F z powrotem do Hello.
Nie należy używać kodowania URL do ukrywania danych wrażliwych. Do szyfrowania całego żądania służy HTTPS. URL-e pojawiają się w logach serwera, historii przeglądarki i nagłówkach Referer, dlatego informacje wrażliwe powinny trafiać do treści żądania, a nie do URL.
Ataki typu open redirect
Atakujący wykorzystują zakodowane URL-e, aby obejść naiwną walidację. Parametr przekierowania zawierający %2F%2Fevil.com dekoduje się do //evil.com, co przeglądarki traktują jako URL względny względem protokołu, wskazujący na domenę atakującego.
Obrona: walidować zdekodowany URL, a nie jego formę zakodowaną. Stosować allowlisty dla domen przekierowań.
Eksploity z podwójnym kodowaniem
WAF sprawdza przychodzące URL-e pod kątem tagów <script>. Atakujący wysyła %253Cscript%253E. WAF widzi tekst zakodowany procentowo i przepuszcza go dalej. Aplikacja dekoduje raz i otrzymuje %3Cscript%3E, kolejne dekodowanie produkuje <script>, omijając filtr.
Obrona: znormalizować wszystkie dane wejściowe (zdekodować w pełni) przed zastosowaniem kontroli bezpieczeństwa. Nie polegać na pojedynczym przebiegu dekodującym.
Więcej o podstawach bezpieczeństwa webowego można znaleźć w naszym przewodniku Najlepsze praktyki bezpieczeństwa webowego.
Limity długości URL i kiedy kodowanie staje się kosztowne
Specyfikacja HTTP nie wyznacza maksymalnej długości URL, ale każda warstwa stosu nakłada własne praktyczne limity.
| Warstwa | Limit |
|---|---|
| Ogólna rekomendacja | 2 000 znaków |
| Chrome, Firefox | ~2 MB (ale serwery odrzucają znacznie wcześniej) |
| Apache (domyślnie) | 8 190 bajtów |
| Nginx (domyślnie) | 8 192 bajty |
| IIS | 16 384 bajty (query string) |
| Sieci CDN, proxy | Różnie — często 4 096–8 192 bajty |
Percent-encoding wydłuża URL-e. Pojedynczy chiński znak rośnie z 1 znaku do 9 (%E4%B8%AD). Emoji rozszerza się do 12. Dwieście chińskich znaków w samym query string daje 1 800 znaków tekstu zakodowanego procentowo.
Co zrobić po przekroczeniu limitu: przenieść dane z parametrów query do treści żądania POST. Dla interfejsów wyszukiwania dobrze sprawdza się endpoint POST przyjmujący JSON.
FAQ
Czym jest kodowanie URL i dlaczego deweloperzy go potrzebują?
Kodowanie URL (percent-encoding) zamienia znaki, które nie są dozwolone w URL-ach, na sekwencje hex w postaci %XX. URL-e wspierają tylko 66 niezarezerwowanych znaków ASCII. Spacje, ampersandy, tekst Unicode i większość interpunkcji muszą zostać zakodowane, inaczej zaburzą strukturę URL.
Jaka jest różnica między encodeURI a encodeURIComponent?
encodeURI() koduje pełny URL, zachowując znaki strukturalne, takie jak ://, /, ? i &. encodeURIComponent() koduje wszystko poza A-Z a-z 0-9 - _ . ~ ! ' ( ) *. Do wartości parametrów query lepiej stosować encodeURIComponent(). encodeURI() warto użyć tylko wtedy, gdy mamy kompletny URL i chcemy poprawić w nim spacje albo znaki spoza ASCII bez naruszania struktury.
Dlaczego %20 czasem pojawia się jako + w URL-ach?
Oba reprezentują spację, ale wywodzą się z różnych standardów. %20 zgodne jest z RFC 3986 i działa wszędzie w URL. + pochodzi ze specyfikacji form encoding HTML i działa wyłącznie w query string. W segmentach ścieżki + to dosłowny plus. W razie wątpliwości warto wybrać %20.
Jak zakodować tekst URL-em w Pythonie, JavaScripcie, Go i Javie?
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 i Java domyślnie używają form encoding (spacja jako +) — dla wyniku zgodnego z RFC 3986 należy zamienić + na %20.
Czy kodowania URL można używać do bezpieczeństwa lub szyfrowania?
Nie. Kodowanie URL jest w pełni odwracalne bez żadnego klucza. Nie zapewnia żadnej poufności. Dane wrażliwe trzeba chronić HTTPS, a nie percent-encoding. URL-e pojawiają się w logach serwera, historii przeglądarki i nagłówkach Referer, dlatego dane wrażliwe powinny trafiać do treści żądania.
Czym jest podwójne kodowanie i jak je naprawić?
Podwójne kodowanie pojawia się wtedy, gdy już zakodowany string zostaje zakodowany ponownie. Znak % w %20 zostaje zakodowany jako %25, dając %2520. Serwery widzą wtedy dosłowny tekst %20 zamiast spacji. Naprawa polega na zdekodowaniu wejścia, a następnie jednorazowym zakodowaniu. Wzorzec %25 z dwiema cyframi szesnastkowymi za nim jest sygnałem ostrzegawczym.
Jaka jest maksymalna długość URL?
Oficjalne maksimum nie istnieje w specyfikacji HTTP. 2 000 znaków to bezpieczny limit dla szerokiej kompatybilności. Apache domyślnie obsługuje 8 190 bajtów, Nginx 8 192 bajty. Znaki spoza ASCII rozszerzają się 3–12 razy przy percent-encoding, więc URL-e zinternacjonalizowane szybciej osiągają limity. Dla dużych ładunków lepiej przejść na POST.